2026-08-06 22:17:40 +10:00
|
|
|
/* tabbed-wlroots.c -- nested tabbed compositor built on wlroots 0.19.
|
|
|
|
|
*
|
|
|
|
|
* Renders a headless wlroots output (via wlr_scene) inside a host Wayland
|
|
|
|
|
* window (the "host" connection, e.g. labwc). Input from the host is
|
|
|
|
|
* forwarded into a wlr_seat, so wlroots handles all sub-surface hit-testing,
|
2026-08-06 22:20:34 +10:00
|
|
|
* enter/leave, input regions and coordinate translation. Clients are always
|
|
|
|
|
* maximized to the host content area; their minimize/maximize/fullscreen
|
|
|
|
|
* requests are ignored.
|
2026-08-06 22:17:40 +10:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#define _GNU_SOURCE
|
|
|
|
|
|
|
|
|
|
#include <errno.h>
|
|
|
|
|
#include <execinfo.h>
|
2026-08-09 02:02:22 +10:00
|
|
|
#include <fcntl.h>
|
2026-08-06 22:17:40 +10:00
|
|
|
#include <linux/memfd.h>
|
|
|
|
|
#include <poll.h>
|
|
|
|
|
#include <signal.h>
|
2026-08-09 02:02:22 +10:00
|
|
|
#include <stdarg.h>
|
2026-08-06 22:17:40 +10:00
|
|
|
#include <stdbool.h>
|
|
|
|
|
#include <stdint.h>
|
|
|
|
|
#include <stdio.h>
|
|
|
|
|
#include <stdlib.h>
|
|
|
|
|
#include <string.h>
|
|
|
|
|
#include <sys/mman.h>
|
|
|
|
|
#include <sys/poll.h>
|
|
|
|
|
#include <sys/wait.h>
|
|
|
|
|
#include <time.h>
|
|
|
|
|
#include <unistd.h>
|
|
|
|
|
|
|
|
|
|
#include <drm_fourcc.h>
|
|
|
|
|
|
|
|
|
|
#include <wayland-client.h>
|
|
|
|
|
#include <wayland-egl.h>
|
|
|
|
|
#include <wayland-server.h>
|
|
|
|
|
|
2026-08-07 10:32:11 +10:00
|
|
|
#include "xdg-decoration-client.h"
|
2026-08-06 22:17:40 +10:00
|
|
|
#include "xdg-shell-client.h"
|
2026-08-10 11:29:07 +10:00
|
|
|
#include "wlr-data-control-client.h"
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
#include <EGL/egl.h>
|
|
|
|
|
#include <EGL/eglext.h>
|
|
|
|
|
#include <GLES2/gl2.h>
|
|
|
|
|
#include <GLES2/gl2ext.h>
|
|
|
|
|
|
|
|
|
|
#include <xkbcommon/xkbcommon.h>
|
|
|
|
|
|
|
|
|
|
#include <wlr/backend.h>
|
|
|
|
|
#include <wlr/backend/headless.h>
|
|
|
|
|
#include <wlr/interfaces/wlr_keyboard.h>
|
|
|
|
|
#include <wlr/render/allocator.h>
|
|
|
|
|
#include <wlr/render/drm_format_set.h>
|
2026-08-08 12:14:29 +10:00
|
|
|
#include <wlr/render/egl.h>
|
|
|
|
|
#include <wlr/render/gles2.h>
|
2026-08-06 22:17:40 +10:00
|
|
|
#include <wlr/render/swapchain.h>
|
|
|
|
|
#include <wlr/render/wlr_renderer.h>
|
|
|
|
|
#include <wlr/types/wlr_buffer.h>
|
|
|
|
|
#include <wlr/types/wlr_compositor.h>
|
2026-08-10 11:29:07 +10:00
|
|
|
#include <wlr/types/wlr_data_control_v1.h>
|
2026-08-06 22:17:40 +10:00
|
|
|
#include <wlr/types/wlr_data_device.h>
|
|
|
|
|
#include <wlr/types/wlr_keyboard.h>
|
|
|
|
|
#include <wlr/types/wlr_linux_dmabuf_v1.h>
|
|
|
|
|
#include <wlr/types/wlr_output.h>
|
|
|
|
|
#include <wlr/types/wlr_pointer.h>
|
|
|
|
|
#include <wlr/types/wlr_scene.h>
|
|
|
|
|
#include <wlr/types/wlr_seat.h>
|
|
|
|
|
#include <wlr/types/wlr_shm.h>
|
|
|
|
|
#include <wlr/types/wlr_subcompositor.h>
|
|
|
|
|
#include <wlr/types/wlr_xdg_shell.h>
|
|
|
|
|
#include <wlr/util/log.h>
|
|
|
|
|
|
2026-08-09 12:15:13 +10:00
|
|
|
/* server.apps grows as needed; there is no tab limit. */
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
/* ===================== Server-side (wlroots) state ===================== */
|
|
|
|
|
|
|
|
|
|
struct tabbed_app {
|
|
|
|
|
char *cmd;
|
|
|
|
|
pid_t pid;
|
|
|
|
|
struct wlr_xdg_toplevel *toplevel;
|
|
|
|
|
struct wlr_scene_tree *tree;
|
|
|
|
|
struct wl_listener destroy;
|
|
|
|
|
struct wl_listener map;
|
|
|
|
|
struct wl_listener commit;
|
|
|
|
|
struct wl_listener request_maximize;
|
|
|
|
|
struct wl_listener request_fullscreen;
|
|
|
|
|
struct wl_listener request_minimize;
|
|
|
|
|
struct wl_listener request_move;
|
|
|
|
|
struct wl_listener request_resize;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
struct tabbed_server {
|
|
|
|
|
struct wl_display *wl_display;
|
|
|
|
|
struct wl_event_loop *loop;
|
|
|
|
|
struct wlr_backend *backend;
|
|
|
|
|
struct wlr_renderer *renderer;
|
|
|
|
|
struct wlr_allocator *allocator;
|
|
|
|
|
struct wlr_scene *scene;
|
|
|
|
|
struct wlr_output *output;
|
|
|
|
|
struct wlr_scene_output *scene_output;
|
2026-08-07 07:48:27 +10:00
|
|
|
struct wlr_scene_rect *bg_rect;
|
2026-08-06 22:17:40 +10:00
|
|
|
struct wlr_seat *seat;
|
|
|
|
|
struct wlr_xdg_shell *xdg_shell;
|
|
|
|
|
struct wlr_keyboard keyboard;
|
|
|
|
|
struct wlr_swapchain *swapchain;
|
|
|
|
|
|
|
|
|
|
struct wl_listener new_output;
|
|
|
|
|
struct wl_listener new_toplevel;
|
2026-08-07 19:07:40 +10:00
|
|
|
struct wl_listener new_popup;
|
2026-08-06 22:17:40 +10:00
|
|
|
struct wl_listener frame;
|
2026-08-10 07:29:10 +10:00
|
|
|
struct wl_event_source *ctl_source;
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
struct wlr_xdg_toplevel *active;
|
2026-08-09 12:15:13 +10:00
|
|
|
struct tabbed_app **apps;
|
2026-08-06 22:17:40 +10:00
|
|
|
int num_apps;
|
2026-08-09 12:15:13 +10:00
|
|
|
int apps_cap;
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
struct xkb_context *xkb_ctx;
|
|
|
|
|
struct xkb_keymap *xkb_keymap;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ===================== Host-side (nested) state ===================== */
|
|
|
|
|
|
|
|
|
|
struct tabbed_host {
|
|
|
|
|
struct wl_display *display;
|
|
|
|
|
struct wl_registry *registry;
|
|
|
|
|
struct wl_compositor *compositor;
|
|
|
|
|
struct wl_surface *surface;
|
|
|
|
|
struct xdg_wm_base *wm_base;
|
|
|
|
|
struct xdg_surface *xdg_surface;
|
|
|
|
|
struct xdg_toplevel *toplevel;
|
2026-08-07 10:32:11 +10:00
|
|
|
struct zxdg_decoration_manager_v1 *decoration;
|
|
|
|
|
struct zxdg_toplevel_decoration_v1 *decoration_obj;
|
2026-08-06 22:17:40 +10:00
|
|
|
struct wl_seat *seat;
|
|
|
|
|
struct wl_keyboard *keyboard;
|
|
|
|
|
struct wl_pointer *pointer;
|
|
|
|
|
bool configured;
|
2026-08-08 19:37:54 +10:00
|
|
|
bool fullscreen;
|
2026-08-06 22:17:40 +10:00
|
|
|
int width, height;
|
|
|
|
|
/* EGL */
|
|
|
|
|
bool egl_ok;
|
|
|
|
|
EGLDisplay egl_display;
|
|
|
|
|
EGLConfig egl_config;
|
|
|
|
|
EGLContext egl_context;
|
|
|
|
|
EGLSurface egl_surface;
|
|
|
|
|
struct wl_egl_window *egl_window;
|
|
|
|
|
/* host modifiers (for tab-switch detection) */
|
|
|
|
|
uint32_t host_mods_depressed;
|
|
|
|
|
uint32_t axis_source;
|
2026-08-09 18:43:10 +10:00
|
|
|
int32_t axis_discrete[2];
|
2026-08-06 22:17:40 +10:00
|
|
|
/* GL program */
|
|
|
|
|
GLuint gl_program;
|
|
|
|
|
GLint gl_pos_loc, gl_uv_loc, gl_tex_loc;
|
|
|
|
|
GLuint import_tex;
|
2026-08-07 19:07:40 +10:00
|
|
|
GLuint popup_tex;
|
2026-08-08 12:14:29 +10:00
|
|
|
/* 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;
|
|
|
|
|
bool present_pending;
|
|
|
|
|
bool back_dirty;
|
2026-08-09 02:53:32 +10:00
|
|
|
/* Keybind repeat for the captured tab-switch binding. The host
|
|
|
|
|
compositor only sends one physical press, so we drive the repeat
|
|
|
|
|
ourselves with a timer (fixed delay/rate), like labwc does for its
|
|
|
|
|
own keybinds. */
|
|
|
|
|
struct wl_event_source *repeat_timer;
|
|
|
|
|
uint32_t repeat_key;
|
|
|
|
|
int repeat_dir;
|
2026-08-06 22:17:40 +10:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static struct tabbed_server server;
|
|
|
|
|
static struct tabbed_host host;
|
|
|
|
|
static char tabbed_socket[64];
|
2026-08-09 12:02:21 +10:00
|
|
|
static char socket_path[256];
|
2026-08-09 01:22:01 +10:00
|
|
|
static volatile sig_atomic_t needs_redraw;
|
2026-08-06 22:17:40 +10:00
|
|
|
static bool frame_pending;
|
2026-08-07 21:23:01 +10:00
|
|
|
static bool shutting_down;
|
2026-08-06 22:17:40 +10:00
|
|
|
|
2026-08-10 11:29:07 +10:00
|
|
|
/* ===================== Clipboard bridge ===================== */
|
|
|
|
|
|
|
|
|
|
enum bridge_backend {
|
|
|
|
|
BRIDGE_BACKEND_NONE = 0,
|
|
|
|
|
BRIDGE_BACKEND_DATA_CONTROL,
|
|
|
|
|
BRIDGE_BACKEND_WL_DATA_DEVICE,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* Bridges the nested seat's clipboard with the host compositor's. Two host
|
|
|
|
|
backends share one inner (nested-seat) implementation:
|
|
|
|
|
- data-control: zwlr_data_control_v1 (labwc/sway/etc.), full duplex,
|
|
|
|
|
independent of keyboard focus.
|
|
|
|
|
- wl_data_device: plain stable protocol (Mutter/GNOME fallback). Writing
|
|
|
|
|
the host selection always works (needs a captured keyboard serial);
|
|
|
|
|
reading the host selection only works while our window is focused.
|
|
|
|
|
Data flows by passing file descriptors straight through in both directions
|
|
|
|
|
(host offer -> nested source, nested source -> host source), so no
|
|
|
|
|
intermediate copy is needed. */
|
|
|
|
|
struct clipboard_bridge {
|
|
|
|
|
enum bridge_backend backend;
|
|
|
|
|
|
|
|
|
|
/* nested (wlroots) seat */
|
|
|
|
|
struct wl_listener request_set_selection;
|
|
|
|
|
struct wl_listener set_selection;
|
|
|
|
|
|
|
|
|
|
/* the nested selection source currently mirrored to the host; serves
|
|
|
|
|
host paste requests until it is destroyed */
|
|
|
|
|
struct wlr_data_source *inner_src;
|
|
|
|
|
struct wl_listener inner_src_destroy;
|
|
|
|
|
bool inner_clear_requested; /* nested client asked to clear the selection */
|
|
|
|
|
bool ignore_next_inner; /* one-shot echo guard for host->nested clears */
|
|
|
|
|
|
|
|
|
|
/* host: standard wl_data_device (always bound; the fallback backend) */
|
|
|
|
|
struct wl_data_device_manager *ddm;
|
|
|
|
|
struct wl_data_device *dd;
|
|
|
|
|
struct wl_data_source *dd_source; /* our host-side source (nested copy) */
|
|
|
|
|
struct wl_data_offer *dd_offer; /* current host selection offer */
|
|
|
|
|
struct wl_data_offer *dd_echo_offer; /* offer describing our own set */
|
|
|
|
|
uint32_t kbd_serial; /* last host keyboard/enter serial */
|
|
|
|
|
|
|
|
|
|
/* host: wlr-data-control backend (if the host exposes it) */
|
|
|
|
|
struct zwlr_data_control_manager_v1 *dc_manager;
|
|
|
|
|
struct zwlr_data_control_device_v1 *dc_device;
|
|
|
|
|
struct zwlr_data_control_source_v1 *dc_source;
|
|
|
|
|
struct zwlr_data_control_offer_v1 *dc_offer;
|
|
|
|
|
struct zwlr_data_control_offer_v1 *dc_echo_offer;
|
|
|
|
|
|
|
|
|
|
/* MIME list currently being collected from a host offer */
|
|
|
|
|
char **mimes;
|
|
|
|
|
size_t mimes_len, mimes_cap;
|
2026-08-10 19:10:58 +10:00
|
|
|
bool dc_pending_echo; /* our own set via data-control: echo not yet consumed */
|
|
|
|
|
bool dd_pending_echo; /* our own set via wl_data_device: echo not yet consumed */
|
2026-08-10 11:29:07 +10:00
|
|
|
bool host_set_pending; /* nested selection not yet on the host; retry on focus */
|
|
|
|
|
|
|
|
|
|
/* Eager text prefetch: when a real host selection is mirrored, its primary
|
|
|
|
|
text MIME is pulled into this buffer at copy time so pastes are served
|
|
|
|
|
instantly instead of asking the host for each one. */
|
|
|
|
|
int prefetch_fd; /* pipe read end, -1 when idle */
|
|
|
|
|
struct wl_event_source *prefetch_src;
|
|
|
|
|
char *prefetch_buf;
|
|
|
|
|
size_t prefetch_len, prefetch_cap;
|
|
|
|
|
char prefetch_mime[64]; /* canonical text MIME, "" if none */
|
|
|
|
|
bool prefetch_ready; /* buffer complete (EOF received) */
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static struct clipboard_bridge bridge = { .prefetch_fd = -1 };
|
|
|
|
|
|
|
|
|
|
static void bridge_cleanup(void);
|
|
|
|
|
static void bridge_init(void);
|
|
|
|
|
static void bridge_attach_seat(void);
|
|
|
|
|
|
2026-08-08 12:14:29 +10:00
|
|
|
/* Temporary frame-dump keys (F8/F9) to attribute the lxterm grey band: F8
|
|
|
|
|
saves the scene buffer (client content as wlroots drew it), F9 saves our
|
|
|
|
|
composited host back buffer. To be removed after diagnosis. */
|
2026-08-09 01:22:01 +10:00
|
|
|
static volatile sig_atomic_t dump_scene_req, dump_host_req;
|
2026-08-08 12:14:29 +10:00
|
|
|
static int dump_seq;
|
2026-08-09 01:22:01 +10:00
|
|
|
static bool client_dumped = false;
|
2026-08-08 12:14:29 +10:00
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
static void set_active_surface(struct wlr_xdg_toplevel *toplevel);
|
|
|
|
|
static void render_and_present(void);
|
2026-08-08 18:31:52 +10:00
|
|
|
static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel);
|
2026-08-09 01:22:01 +10:00
|
|
|
static int app_index(struct tabbed_app *app);
|
|
|
|
|
|
2026-08-09 02:02:22 +10:00
|
|
|
/* Logging levels: quiet by default so a plain launch writes nothing to the
|
|
|
|
|
launching terminal except fatal errors. TABBED_LOG=1 shows status lines,
|
2026-08-09 13:02:46 +10:00
|
|
|
TABBED_LOG=2 adds the per-frame [diag] trace. The F9/F10 keys, SIGUSR1 and
|
2026-08-09 02:02:22 +10:00
|
|
|
TABBED_AUTODUMP dumps always write their .ppm files regardless of level. */
|
|
|
|
|
enum { TABBED_LOG_QUIET = 0, TABBED_LOG_INFO = 1, TABBED_LOG_DEBUG = 2 };
|
|
|
|
|
|
|
|
|
|
static int tabbed_log_level(void) {
|
|
|
|
|
static volatile sig_atomic_t level = -1;
|
|
|
|
|
if (level < 0) {
|
|
|
|
|
const char *v = getenv("TABBED_LOG");
|
|
|
|
|
int l = v ? atoi(v) : 0;
|
|
|
|
|
if (l < TABBED_LOG_QUIET) l = TABBED_LOG_QUIET;
|
|
|
|
|
if (l > TABBED_LOG_DEBUG) l = TABBED_LOG_DEBUG;
|
|
|
|
|
level = l;
|
|
|
|
|
}
|
|
|
|
|
return level;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:28:36 +10:00
|
|
|
static void tabbed_vlog(int level, const char *fmt, va_list args) {
|
2026-08-09 02:02:22 +10:00
|
|
|
if (level > tabbed_log_level())
|
|
|
|
|
return;
|
2026-08-09 02:28:36 +10:00
|
|
|
vfprintf(stderr, fmt, args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void tabbed_log(int level, const char *fmt, ...) {
|
2026-08-09 02:02:22 +10:00
|
|
|
va_list args;
|
|
|
|
|
va_start(args, fmt);
|
2026-08-09 02:28:36 +10:00
|
|
|
tabbed_vlog(level, fmt, args);
|
2026-08-09 02:02:22 +10:00
|
|
|
va_end(args);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:28:36 +10:00
|
|
|
/* Route libxkbcommon's own log output (which otherwise writes
|
|
|
|
|
"xkbcommon: ERROR: ..." straight to stderr, bypassing TABBED_LOG) through
|
|
|
|
|
our level system. Errors surface at info level; warnings and below are
|
|
|
|
|
debug-only, so a quiet launch no longer leaks parse failures to the
|
|
|
|
|
launching terminal. */
|
|
|
|
|
static void tabbed_xkb_log(struct xkb_context *ctx, enum xkb_log_level level,
|
|
|
|
|
const char *fmt, va_list args)
|
|
|
|
|
{
|
|
|
|
|
(void)ctx;
|
|
|
|
|
int l = (level <= XKB_LOG_LEVEL_ERROR) ? TABBED_LOG_INFO : TABBED_LOG_DEBUG;
|
|
|
|
|
if (l > tabbed_log_level())
|
|
|
|
|
return;
|
|
|
|
|
fputs("tabbed-wlroots: xkbcommon: ", stderr);
|
|
|
|
|
vfprintf(stderr, fmt, args);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:02:22 +10:00
|
|
|
static void tabbed_log_level_init(void) {
|
|
|
|
|
(void)tabbed_log_level();
|
2026-08-09 01:22:01 +10:00
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
|
2026-08-07 21:23:01 +10:00
|
|
|
/* Terminate every launched client: SIGTERM first for a graceful shutdown,
|
|
|
|
|
then SIGKILL any stragglers after a short grace period. */
|
|
|
|
|
static void terminate_clients(void) {
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
|
|
|
if (server.apps[i]->pid > 0)
|
|
|
|
|
kill(-server.apps[i]->pid, SIGTERM);
|
|
|
|
|
}
|
|
|
|
|
struct timespec wait = { .tv_sec = 0, .tv_nsec = 150000000 };
|
|
|
|
|
nanosleep(&wait, NULL);
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
|
|
|
if (server.apps[i]->pid > 0)
|
|
|
|
|
kill(-server.apps[i]->pid, SIGKILL);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 12:02:21 +10:00
|
|
|
/* Remove the session's Wayland socket file (and its companion .lock file,
|
|
|
|
|
both created by wl_display_add_socket), if present. The full path is
|
|
|
|
|
precomputed at startup so this only ever calls unlink()/snprintf(), which
|
|
|
|
|
are async-signal-safe and therefore safe from the signal/crash handlers
|
|
|
|
|
too. A missing file is fine: ENOENT is ignored, i.e. nothing to clean. */
|
|
|
|
|
static void remove_socket_file(void) {
|
|
|
|
|
unlink(socket_path);
|
|
|
|
|
char lock_path[sizeof(socket_path) + 6];
|
|
|
|
|
snprintf(lock_path, sizeof(lock_path), "%s.lock", socket_path);
|
|
|
|
|
unlink(lock_path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 12:15:13 +10:00
|
|
|
/* 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) {
|
|
|
|
|
if (n <= server.apps_cap) return 0;
|
|
|
|
|
struct tabbed_app **grown =
|
|
|
|
|
realloc(server.apps, (size_t)n * sizeof(*grown));
|
|
|
|
|
if (!grown) return -1;
|
|
|
|
|
server.apps = grown;
|
|
|
|
|
server.apps_cap = n;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 21:23:01 +10:00
|
|
|
/* Full shutdown of the nested session: take down the clients first, then
|
|
|
|
|
free our own allocations and exit. Guarded so the various close paths
|
|
|
|
|
(host window close, last tab closed) only run once. */
|
|
|
|
|
static void shutdown_cleanup(void) {
|
|
|
|
|
if (shutting_down) return;
|
|
|
|
|
shutting_down = true;
|
|
|
|
|
terminate_clients();
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
|
|
|
free(server.apps[i]->cmd);
|
|
|
|
|
free(server.apps[i]);
|
|
|
|
|
}
|
2026-08-09 12:15:13 +10:00
|
|
|
free(server.apps);
|
|
|
|
|
server.apps = NULL;
|
2026-08-07 21:23:01 +10:00
|
|
|
if (server.xkb_keymap) xkb_keymap_unref(server.xkb_keymap);
|
|
|
|
|
if (server.xkb_ctx) xkb_context_unref(server.xkb_ctx);
|
2026-08-10 11:29:07 +10:00
|
|
|
bridge_cleanup();
|
2026-08-09 12:02:21 +10:00
|
|
|
remove_socket_file();
|
2026-08-07 21:23:01 +10:00
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
/* ===================== EGL helpers ===================== */
|
|
|
|
|
|
|
|
|
|
static PFNEGLCREATEIMAGEKHRPROC pfn_eglCreateImageKHR;
|
|
|
|
|
static PFNEGLDESTROYIMAGEKHRPROC pfn_eglDestroyImageKHR;
|
|
|
|
|
static PFNEGLGETPLATFORMDISPLAYEXTPROC pfn_eglGetPlatformDisplayEXT;
|
|
|
|
|
static PFNGLEGLIMAGETARGETTEXTURE2DOESPROC pfn_glEGLImageTargetTexture2DOES;
|
|
|
|
|
|
|
|
|
|
static void *egl_proc(const char *name) {
|
|
|
|
|
void *p = (void *)eglGetProcAddress(name);
|
|
|
|
|
if (!p)
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: EGL proc %s unavailable\n", name);
|
|
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static GLuint compile_shader(GLenum type, const char *src) {
|
|
|
|
|
GLuint sh = glCreateShader(type);
|
|
|
|
|
glShaderSource(sh, 1, &src, NULL);
|
|
|
|
|
glCompileShader(sh);
|
|
|
|
|
GLint ok;
|
|
|
|
|
glGetShaderiv(sh, GL_COMPILE_STATUS, &ok);
|
|
|
|
|
if (!ok) {
|
|
|
|
|
char log[512];
|
|
|
|
|
glGetShaderInfoLog(sh, sizeof(log), NULL, log);
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: shader error: %s\n", log);
|
|
|
|
|
glDeleteShader(sh);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return sh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool gl_setup(void) {
|
|
|
|
|
const char *vs =
|
|
|
|
|
"attribute vec2 a_pos;\n"
|
|
|
|
|
"attribute vec2 a_uv;\n"
|
|
|
|
|
"varying vec2 v_uv;\n"
|
|
|
|
|
"void main() {\n"
|
|
|
|
|
" v_uv = a_uv;\n"
|
|
|
|
|
" gl_Position = vec4(a_pos, 0.0, 1.0);\n"
|
|
|
|
|
"}\n";
|
|
|
|
|
const char *fs =
|
|
|
|
|
"precision mediump float;\n"
|
|
|
|
|
"varying vec2 v_uv;\n"
|
|
|
|
|
"uniform sampler2D u_tex;\n"
|
|
|
|
|
"void main() {\n"
|
|
|
|
|
" gl_FragColor = texture2D(u_tex, v_uv);\n"
|
|
|
|
|
"}\n";
|
|
|
|
|
GLuint vsh = compile_shader(GL_VERTEX_SHADER, vs);
|
|
|
|
|
GLuint fsh = compile_shader(GL_FRAGMENT_SHADER, fs);
|
|
|
|
|
if (!vsh || !fsh) return false;
|
|
|
|
|
host.gl_program = glCreateProgram();
|
|
|
|
|
glAttachShader(host.gl_program, vsh);
|
|
|
|
|
glAttachShader(host.gl_program, fsh);
|
|
|
|
|
glBindAttribLocation(host.gl_program, 0, "a_pos");
|
|
|
|
|
glBindAttribLocation(host.gl_program, 1, "a_uv");
|
|
|
|
|
glLinkProgram(host.gl_program);
|
|
|
|
|
GLint ok;
|
|
|
|
|
glGetProgramiv(host.gl_program, GL_LINK_STATUS, &ok);
|
|
|
|
|
glDeleteShader(vsh);
|
|
|
|
|
glDeleteShader(fsh);
|
|
|
|
|
if (!ok) {
|
|
|
|
|
char log[512];
|
|
|
|
|
glGetProgramInfoLog(host.gl_program, sizeof(log), NULL, log);
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: link error: %s\n", log);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
glGenTextures(1, &host.import_tex);
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, host.import_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);
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
2026-08-07 19:07:40 +10:00
|
|
|
|
|
|
|
|
glGenTextures(1, &host.popup_tex);
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, host.popup_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);
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
2026-08-06 22:17:40 +10:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Blit a source rect of a texture into the window at the given destination
|
|
|
|
|
offset (window coords), clipped to the window. */
|
|
|
|
|
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)
|
|
|
|
|
{
|
|
|
|
|
if (win_w <= 0 || win_h <= 0) return;
|
|
|
|
|
if (src_w <= 0 || src_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;
|
|
|
|
|
int wx0 = dst_x + cx;
|
|
|
|
|
int wy0 = dst_y + cy;
|
|
|
|
|
int cw = src_w - cx;
|
|
|
|
|
int ch = src_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 nx0 = -1.0f + 2.0f * (float)wx0 / (float)win_w;
|
|
|
|
|
float ny0 = 1.0f - 2.0f * (float)wy0 / (float)win_h;
|
|
|
|
|
float nx1 = -1.0f + 2.0f * (float)(wx0 + cw) / (float)win_w;
|
|
|
|
|
float ny1 = 1.0f - 2.0f * (float)(wy0 + ch) / (float)win_h;
|
|
|
|
|
|
|
|
|
|
GLfloat verts[8] = {
|
|
|
|
|
nx0, ny0,
|
|
|
|
|
nx1, ny0,
|
|
|
|
|
nx1, ny1,
|
|
|
|
|
nx0, ny1,
|
|
|
|
|
};
|
|
|
|
|
GLfloat uvs[8] = {
|
|
|
|
|
u0, v0,
|
|
|
|
|
u1, v0,
|
|
|
|
|
u1, v1,
|
|
|
|
|
u0, v1,
|
|
|
|
|
};
|
|
|
|
|
GLushort idx[6] = { 0, 1, 2, 0, 2, 3 };
|
|
|
|
|
|
|
|
|
|
glUseProgram(host.gl_program);
|
|
|
|
|
glActiveTexture(GL_TEXTURE0);
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, tex);
|
|
|
|
|
glUniform1i(host.gl_tex_loc, 0);
|
|
|
|
|
|
|
|
|
|
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, verts);
|
|
|
|
|
glEnableVertexAttribArray(0);
|
|
|
|
|
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, uvs);
|
|
|
|
|
glEnableVertexAttribArray(1);
|
|
|
|
|
|
|
|
|
|
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, idx);
|
|
|
|
|
|
|
|
|
|
glDisableVertexAttribArray(0);
|
|
|
|
|
glDisableVertexAttribArray(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===================== Server-side wlroots setup ===================== */
|
|
|
|
|
|
|
|
|
|
static void swapchain_resize(int width, int height) {
|
|
|
|
|
if (width < 50) width = 50;
|
|
|
|
|
if (height < 50) height = 50;
|
|
|
|
|
static uint64_t mods[] = { DRM_FORMAT_MOD_LINEAR };
|
|
|
|
|
struct wlr_drm_format fmt = {
|
|
|
|
|
.format = DRM_FORMAT_ARGB8888,
|
|
|
|
|
.len = 1,
|
|
|
|
|
.capacity = 1,
|
|
|
|
|
.modifiers = mods,
|
|
|
|
|
};
|
|
|
|
|
if (server.swapchain) {
|
|
|
|
|
wlr_swapchain_destroy(server.swapchain);
|
|
|
|
|
server.swapchain = NULL;
|
|
|
|
|
}
|
|
|
|
|
server.swapchain = wlr_swapchain_create(server.allocator, width, height, &fmt);
|
|
|
|
|
if (!server.swapchain)
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: swapchain create failed at %dx%d\n",
|
|
|
|
|
width, height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void output_resize(int width, int height) {
|
|
|
|
|
if (width < 50) width = 50;
|
|
|
|
|
if (height < 50) height = 50;
|
|
|
|
|
if (!server.output || !server.allocator) return;
|
|
|
|
|
|
|
|
|
|
struct wlr_output_state state;
|
|
|
|
|
wlr_output_state_init(&state);
|
|
|
|
|
wlr_output_state_set_enabled(&state, true);
|
|
|
|
|
wlr_output_state_set_custom_mode(&state, width, height, 0);
|
|
|
|
|
wlr_output_commit_state(server.output, &state);
|
|
|
|
|
wlr_output_state_finish(&state);
|
|
|
|
|
|
|
|
|
|
swapchain_resize(width, height);
|
|
|
|
|
|
2026-08-07 07:48:27 +10:00
|
|
|
if (server.bg_rect)
|
|
|
|
|
wlr_scene_rect_set_size(server.bg_rect, width, height);
|
|
|
|
|
|
2026-08-07 10:38:52 +10:00
|
|
|
/* Keep every mapped client at the current maximized size so switching
|
|
|
|
|
tabs is instant and no tab is left small after a host resize. */
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
|
|
|
struct tabbed_app *app = server.apps[i];
|
|
|
|
|
if (!app->toplevel) continue;
|
|
|
|
|
wlr_xdg_toplevel_set_maximized(app->toplevel, true);
|
|
|
|
|
wlr_xdg_toplevel_set_size(app->toplevel, width, height);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
needs_redraw = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_output_frame(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)listener; (void)data;
|
|
|
|
|
frame_pending = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_new_output(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)listener;
|
|
|
|
|
struct wlr_output *output = data;
|
|
|
|
|
|
|
|
|
|
if (!wlr_output_init_render(output, server.allocator, server.renderer)) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: output init_render failed\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
wlr_output_create_global(output, server.wl_display);
|
|
|
|
|
|
2026-08-07 07:48:27 +10:00
|
|
|
server.output = output;
|
2026-08-06 22:17:40 +10:00
|
|
|
server.scene_output = wlr_scene_output_create(server.scene, output);
|
|
|
|
|
wlr_scene_output_set_position(server.scene_output, 0, 0);
|
|
|
|
|
|
|
|
|
|
/* Render whenever the output is ready for a frame (refresh-rate driven),
|
|
|
|
|
like tinywl; this is what drives client frame callbacks / surface
|
|
|
|
|
enter events even before any damage has accumulated. */
|
|
|
|
|
server.frame.notify = handle_output_frame;
|
|
|
|
|
wl_signal_add(&output->events.frame, &server.frame);
|
|
|
|
|
|
|
|
|
|
/* Enable the output up front so buffer commits succeed even before the
|
|
|
|
|
host window has been configured. */
|
|
|
|
|
int w = output->width > 0 ? output->width : host.width;
|
|
|
|
|
int h = output->height > 0 ? output->height : host.height;
|
|
|
|
|
struct wlr_output_state state;
|
|
|
|
|
wlr_output_state_init(&state);
|
|
|
|
|
wlr_output_state_set_enabled(&state, true);
|
|
|
|
|
wlr_output_state_set_custom_mode(&state, w, h, 0);
|
|
|
|
|
wlr_output_commit_state(output, &state);
|
|
|
|
|
wlr_output_state_finish(&state);
|
|
|
|
|
|
|
|
|
|
swapchain_resize(w, h);
|
2026-08-07 07:48:27 +10:00
|
|
|
|
2026-08-09 01:22:01 +10:00
|
|
|
/* An opaque full-output background node gives the scene a deterministic
|
|
|
|
|
backdrop so the nested frame is never transparent. (Direct scanout is
|
|
|
|
|
disabled separately above; a rect alone cannot prevent it because
|
|
|
|
|
wlroots culls a rect that a covering client surface occludes, leaving
|
|
|
|
|
a single render-list entry which is exactly what triggers scanout.) */
|
2026-08-07 07:48:27 +10:00
|
|
|
server.bg_rect = wlr_scene_rect_create(&server.scene->tree, w, h,
|
|
|
|
|
(float[]){ 0.0f, 0.0f, 0.0f, 1.0f });
|
|
|
|
|
if (server.bg_rect)
|
|
|
|
|
wlr_scene_node_lower_to_bottom(&server.bg_rect->node);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
2026-08-07 10:53:42 +10:00
|
|
|
struct tabbed_app *app = wl_container_of(listener, app, destroy);
|
|
|
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
2026-08-06 22:17:40 +10:00
|
|
|
app->toplevel = NULL;
|
|
|
|
|
app->tree = NULL;
|
2026-08-07 10:53:42 +10:00
|
|
|
|
|
|
|
|
/* wlroots emits destroy with data=NULL and asserts every toplevel
|
|
|
|
|
event listener list is empty afterwards; remove all listeners we
|
|
|
|
|
attached to satisfy that and avoid use-after-free. */
|
|
|
|
|
wl_list_remove(&app->destroy.link);
|
|
|
|
|
wl_list_remove(&app->map.link);
|
|
|
|
|
wl_list_remove(&app->commit.link);
|
|
|
|
|
wl_list_remove(&app->request_maximize.link);
|
|
|
|
|
wl_list_remove(&app->request_fullscreen.link);
|
|
|
|
|
wl_list_remove(&app->request_minimize.link);
|
|
|
|
|
wl_list_remove(&app->request_move.link);
|
|
|
|
|
wl_list_remove(&app->request_resize.link);
|
|
|
|
|
|
|
|
|
|
if (server.active != NULL && server.active == toplevel) {
|
2026-08-06 22:17:40 +10:00
|
|
|
server.active = NULL;
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
|
|
|
if (server.apps[i]->toplevel) {
|
|
|
|
|
set_active_surface(server.apps[i]->toplevel);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
/* last tab closed: close the host window and exit */
|
|
|
|
|
xdg_toplevel_destroy(host.toplevel);
|
|
|
|
|
wl_surface_destroy(host.surface);
|
2026-08-07 21:23:01 +10:00
|
|
|
shutdown_cleanup();
|
2026-08-06 22:17:40 +10:00
|
|
|
exit(0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_request_maximize(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
struct tabbed_app *app = wl_container_of(listener, app, request_maximize);
|
|
|
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
2026-08-07 11:15:53 +10:00
|
|
|
if (toplevel->base->initialized)
|
|
|
|
|
wlr_xdg_toplevel_set_maximized(toplevel, true);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_request_fullscreen(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
struct tabbed_app *app = wl_container_of(listener, app, request_fullscreen);
|
|
|
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
2026-08-07 11:15:53 +10:00
|
|
|
if (toplevel->base->initialized)
|
|
|
|
|
wlr_xdg_toplevel_set_fullscreen(toplevel, false);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_request_minimize(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)listener; (void)data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_request_move(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)listener; (void)data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_request_resize(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)listener; (void)data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 07:48:27 +10:00
|
|
|
static void position_toplevel(struct tabbed_app *app) {
|
|
|
|
|
if (!app || !app->tree) return;
|
2026-08-07 10:27:48 +10:00
|
|
|
/* CSD clients (foot/kitty) reserve a titlebar gutter above their
|
|
|
|
|
content, which we don't paint, so it shows as a dark bar and their
|
|
|
|
|
content sits too low (bottom row/cursor cropped). Shift them up by
|
|
|
|
|
that gutter so the dark bar is cropped instead of the terminal's
|
|
|
|
|
bottom. Clients with no gutter (geometry.y >= 0, e.g. wltest) are
|
|
|
|
|
left untouched. */
|
2026-08-07 07:48:27 +10:00
|
|
|
struct wlr_box geom = app->toplevel->base->geometry;
|
2026-08-07 10:27:48 +10:00
|
|
|
int y = -geom.y;
|
|
|
|
|
if (geom.y < 0)
|
2026-08-09 19:15:50 +10:00
|
|
|
y = 0;
|
2026-08-07 10:27:48 +10:00
|
|
|
wlr_scene_node_set_position(&app->tree->node, -geom.x, y);
|
2026-08-07 07:48:27 +10:00
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
struct tabbed_app *app = wl_container_of(listener, app, commit);
|
|
|
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
|
|
|
|
if (toplevel->base->initial_commit) {
|
|
|
|
|
wlr_xdg_toplevel_set_maximized(toplevel, true);
|
|
|
|
|
wlr_xdg_toplevel_set_size(toplevel, server.output->width,
|
|
|
|
|
server.output->height);
|
|
|
|
|
}
|
2026-08-07 07:48:27 +10:00
|
|
|
/* Geometry can settle after the first frames (kitty/foot report a
|
|
|
|
|
negative y once they decide on their titlebar), so keep the scene
|
|
|
|
|
position in sync with every commit instead of only at activation. */
|
|
|
|
|
position_toplevel(app);
|
2026-08-09 01:22:01 +10:00
|
|
|
|
|
|
|
|
/* [diag] What does this client commit, and is its scene node visible? */
|
|
|
|
|
struct wlr_surface *s = toplevel->base->surface;
|
|
|
|
|
struct wlr_buffer *cbuf = s ? s->current.buffer : NULL;
|
|
|
|
|
struct wlr_dmabuf_attributes dma;
|
|
|
|
|
static struct wlr_buffer *last_logged = NULL;
|
|
|
|
|
if (cbuf && cbuf != last_logged) {
|
|
|
|
|
last_logged = cbuf;
|
|
|
|
|
bool is_dma = wlr_buffer_get_dmabuf(cbuf, &dma);
|
|
|
|
|
bool node_on = app->tree ? app->tree->node.enabled : false;
|
2026-08-09 02:02:22 +10:00
|
|
|
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
|
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] commit app=%d geom=%dx%d+%d+%d "
|
|
|
|
|
"buf=%dx%d dma=%d%s node_enabled=%d active=%d\n",
|
2026-08-09 01:22:01 +10:00
|
|
|
app_index(app),
|
|
|
|
|
toplevel->base->geometry.width,
|
|
|
|
|
toplevel->base->geometry.height,
|
|
|
|
|
toplevel->base->geometry.x,
|
|
|
|
|
toplevel->base->geometry.y,
|
|
|
|
|
s->current.buffer_width,
|
|
|
|
|
s->current.buffer_height,
|
|
|
|
|
is_dma,
|
|
|
|
|
is_dma ? (dma.n_planes ? "" : " planes=0") : "",
|
|
|
|
|
node_on,
|
|
|
|
|
server.active == toplevel);
|
|
|
|
|
if (is_dma) {
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] dmabuf planes=%d "
|
|
|
|
|
"format=0x%x mod=0x%llx\n",
|
2026-08-09 01:22:01 +10:00
|
|
|
dma.n_planes, dma.format,
|
|
|
|
|
(unsigned long long)dma.modifier);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!is_dma) {
|
2026-08-09 02:02:22 +10:00
|
|
|
/* shm buffer: flag for TABBED_AUTODUMP; at debug level also count
|
|
|
|
|
non-black pixels and save the client's own buffer so we can tell
|
|
|
|
|
"client painted nothing" from "we failed to import". */
|
2026-08-09 01:22:01 +10:00
|
|
|
void *data;
|
|
|
|
|
uint32_t fmt;
|
|
|
|
|
size_t stride;
|
|
|
|
|
if (wlr_buffer_begin_data_ptr_access(
|
|
|
|
|
cbuf, WLR_BUFFER_DATA_PTR_ACCESS_READ,
|
|
|
|
|
&data, &fmt, &stride)) {
|
|
|
|
|
client_dumped = true;
|
2026-08-09 02:02:22 +10:00
|
|
|
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
2026-08-09 01:22:01 +10:00
|
|
|
int bw = cbuf->width, bh = cbuf->height;
|
|
|
|
|
unsigned long nonblack = 0, lum = 0;
|
|
|
|
|
unsigned char *p = data;
|
|
|
|
|
for (int yy = 0; yy < bh; yy++) {
|
|
|
|
|
unsigned char *row = p + (size_t)yy * stride;
|
|
|
|
|
for (int xx = 0; xx < bw; xx++) {
|
|
|
|
|
unsigned long v = row[xx*4+0] + row[xx*4+1]
|
|
|
|
|
+ row[xx*4+2];
|
|
|
|
|
lum += v;
|
|
|
|
|
if (v > 12) nonblack++;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] shm fmt=0x%x "
|
|
|
|
|
"nonblack=%lu/%lu mean=%lu\n", fmt, nonblack,
|
2026-08-09 01:22:01 +10:00
|
|
|
(unsigned long)bw * bh,
|
|
|
|
|
lum / ((unsigned long)bw * bh + 1));
|
|
|
|
|
char cpath[64];
|
|
|
|
|
snprintf(cpath, sizeof(cpath), "/tmp/client_%d.ppm",
|
|
|
|
|
dump_seq);
|
|
|
|
|
FILE *cf = fopen(cpath, "wb");
|
|
|
|
|
if (cf) {
|
|
|
|
|
fprintf(cf, "P6\n%d %d\n255\n", bw, bh);
|
|
|
|
|
unsigned char *cp = data;
|
|
|
|
|
for (int yy = 0; yy < bh; yy++) {
|
|
|
|
|
unsigned char *crow = cp + (size_t)yy * stride;
|
|
|
|
|
for (int xx = 0; xx < bw; xx++) {
|
|
|
|
|
fputc(crow[xx*4+0], cf);
|
|
|
|
|
fputc(crow[xx*4+1], cf);
|
|
|
|
|
fputc(crow[xx*4+2], cf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fclose(cf);
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] client buffer "
|
|
|
|
|
"-> %s\n", cpath);
|
2026-08-09 01:22:01 +10:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
wlr_buffer_end_data_ptr_access(cbuf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_toplevel_map(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
struct tabbed_app *app = wl_container_of(listener, app, map);
|
|
|
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
2026-08-09 02:02:22 +10:00
|
|
|
if (tabbed_log_level() >= TABBED_LOG_DEBUG)
|
|
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] map app=%d\n", app_index(app));
|
2026-08-06 22:17:40 +10:00
|
|
|
wlr_xdg_toplevel_set_activated(toplevel, true);
|
|
|
|
|
set_active_surface(toplevel);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_new_toplevel(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)listener;
|
|
|
|
|
struct wlr_xdg_toplevel *toplevel = data;
|
|
|
|
|
|
|
|
|
|
struct tabbed_app *app = NULL;
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
|
|
|
if (!server.apps[i]->toplevel) {
|
|
|
|
|
app = server.apps[i];
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-08-07 20:31:24 +10:00
|
|
|
if (!app) {
|
|
|
|
|
/* No slot for this toplevel (e.g. a dialog/second window opened by
|
|
|
|
|
an already-running client). Give it its own tab so it appears on
|
2026-08-08 18:31:52 +10:00
|
|
|
top and stays reachable via tab cycling. Place the new tab right
|
|
|
|
|
after the tab that created it (protocol parent, else the active
|
|
|
|
|
tab); fall back to the far right if neither can be identified. */
|
2026-08-09 12:15:13 +10:00
|
|
|
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
|
2026-08-07 20:31:24 +10:00
|
|
|
app = calloc(1, sizeof(*app));
|
|
|
|
|
if (!app) return;
|
|
|
|
|
app->pid = -1;
|
|
|
|
|
app->cmd = NULL;
|
2026-08-08 18:31:52 +10:00
|
|
|
|
|
|
|
|
int anchor = -1;
|
|
|
|
|
struct tabbed_app *pa = toplevel->parent
|
|
|
|
|
? app_for_toplevel(toplevel->parent) : NULL;
|
|
|
|
|
if (!pa)
|
|
|
|
|
pa = app_for_toplevel(server.active);
|
|
|
|
|
if (pa)
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
|
|
|
if (server.apps[i] == pa) {
|
|
|
|
|
anchor = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (anchor < 0) {
|
|
|
|
|
server.apps[server.num_apps] = app;
|
|
|
|
|
} else {
|
|
|
|
|
int at = anchor + 1;
|
|
|
|
|
if (at < server.num_apps)
|
|
|
|
|
memmove(&server.apps[at + 1], &server.apps[at],
|
|
|
|
|
(server.num_apps - at) * sizeof(*server.apps));
|
|
|
|
|
server.apps[at] = app;
|
|
|
|
|
}
|
|
|
|
|
server.num_apps++;
|
2026-08-07 20:31:24 +10:00
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
app->toplevel = toplevel;
|
|
|
|
|
app->tree = wlr_scene_xdg_surface_create(&server.scene->tree,
|
|
|
|
|
toplevel->base);
|
|
|
|
|
/* hidden until it becomes the active tab */
|
|
|
|
|
wlr_scene_node_set_enabled(&app->tree->node, false);
|
|
|
|
|
|
|
|
|
|
app->destroy.notify = handle_toplevel_destroy;
|
|
|
|
|
wl_signal_add(&toplevel->events.destroy, &app->destroy);
|
|
|
|
|
|
|
|
|
|
app->map.notify = handle_toplevel_map;
|
|
|
|
|
wl_signal_add(&toplevel->base->surface->events.map, &app->map);
|
|
|
|
|
|
|
|
|
|
app->commit.notify = handle_toplevel_commit;
|
|
|
|
|
wl_signal_add(&toplevel->base->surface->events.commit, &app->commit);
|
|
|
|
|
|
|
|
|
|
app->request_maximize.notify = handle_request_maximize;
|
|
|
|
|
wl_signal_add(&toplevel->events.request_maximize, &app->request_maximize);
|
|
|
|
|
app->request_fullscreen.notify = handle_request_fullscreen;
|
|
|
|
|
wl_signal_add(&toplevel->events.request_fullscreen, &app->request_fullscreen);
|
|
|
|
|
app->request_minimize.notify = handle_request_minimize;
|
|
|
|
|
wl_signal_add(&toplevel->events.request_minimize, &app->request_minimize);
|
|
|
|
|
app->request_move.notify = handle_request_move;
|
|
|
|
|
wl_signal_add(&toplevel->events.request_move, &app->request_move);
|
|
|
|
|
app->request_resize.notify = handle_request_resize;
|
|
|
|
|
wl_signal_add(&toplevel->events.request_resize, &app->request_resize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===================== Tab management ===================== */
|
|
|
|
|
|
|
|
|
|
static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel) {
|
|
|
|
|
if (!toplevel) return NULL;
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
|
|
|
if (server.apps[i]->toplevel == toplevel)
|
|
|
|
|
return server.apps[i];
|
|
|
|
|
return NULL;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 01:22:01 +10:00
|
|
|
static int app_index(struct tabbed_app *app) {
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
|
|
|
if (server.apps[i] == app)
|
|
|
|
|
return i;
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 19:07:40 +10:00
|
|
|
struct tabbed_popup {
|
|
|
|
|
struct wlr_xdg_surface *xdg_surface;
|
|
|
|
|
struct wlr_scene_tree *scene;
|
|
|
|
|
struct wl_list link;
|
|
|
|
|
struct wl_listener commit;
|
|
|
|
|
struct wl_listener destroy;
|
|
|
|
|
struct wl_listener reposition;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* Popups are also drawn by us (host-side) for the presented frame, because
|
|
|
|
|
wlroots' own premultiplied-alpha rendering of these ARGB menus produces
|
|
|
|
|
nothing in this nested EGL pipeline. This list tracks the live ones. */
|
|
|
|
|
static struct wl_list popups = { &popups, &popups };
|
|
|
|
|
|
|
|
|
|
/* Enough of the host window for popups to be positioned within; open menus
|
|
|
|
|
may exceed the toplevel but we keep them inside the visible area. */
|
|
|
|
|
static void popup_usable_box(struct wlr_box *box) {
|
|
|
|
|
box->x = 0;
|
|
|
|
|
box->y = 0;
|
|
|
|
|
if (server.output) {
|
|
|
|
|
box->width = server.output->width;
|
|
|
|
|
box->height = server.output->height;
|
|
|
|
|
} else {
|
|
|
|
|
box->width = host.width;
|
|
|
|
|
box->height = host.height;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Menu popups (e.g. galculator's) submit ARGB buffers that wlroots renders
|
|
|
|
|
with premultiplied-alpha blending, which produces nothing in this nested
|
|
|
|
|
EGL pipeline. We therefore draw them ourselves into the presented frame
|
|
|
|
|
(see composite_popups in blit_scene_buffer). */
|
|
|
|
|
static void popup_handle_commit(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
struct tabbed_popup *tp = wl_container_of(listener, tp, commit);
|
|
|
|
|
struct wlr_xdg_surface *xdg_surface = tp->xdg_surface;
|
|
|
|
|
/* The client cannot map/render the popup until it receives its initial
|
|
|
|
|
configure, which wlroots only sends via wlr_xdg_popup_unconstrain_from
|
|
|
|
|
(which also adjusts geometry within the usable area). initial_commit is
|
|
|
|
|
true on the first commit; later commits are ignored. */
|
|
|
|
|
if (xdg_surface->initial_commit && xdg_surface->popup) {
|
|
|
|
|
struct wlr_box box;
|
|
|
|
|
popup_usable_box(&box);
|
|
|
|
|
wlr_xdg_popup_unconstrain_from_box(xdg_surface->popup, &box);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void popup_handle_reposition(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
struct tabbed_popup *tp = wl_container_of(listener, tp, reposition);
|
|
|
|
|
if (tp->xdg_surface->popup) {
|
|
|
|
|
struct wlr_box box;
|
|
|
|
|
popup_usable_box(&box);
|
|
|
|
|
wlr_xdg_popup_unconstrain_from_box(tp->xdg_surface->popup, &box);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void popup_handle_destroy(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
struct tabbed_popup *tp = wl_container_of(listener, tp, destroy);
|
|
|
|
|
wl_list_remove(&tp->link);
|
|
|
|
|
wl_list_remove(&tp->commit.link);
|
|
|
|
|
wl_list_remove(&tp->destroy.link);
|
|
|
|
|
wl_list_remove(&tp->reposition.link);
|
|
|
|
|
free(tp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void create_popup(struct wlr_xdg_popup *popup,
|
|
|
|
|
struct wlr_scene_tree *parent_tree) {
|
|
|
|
|
struct wlr_xdg_surface *xdg_surface = popup->base;
|
|
|
|
|
struct tabbed_popup *tp = calloc(1, sizeof(*tp));
|
|
|
|
|
if (!tp) return;
|
|
|
|
|
tp->xdg_surface = xdg_surface;
|
|
|
|
|
tp->scene = wlr_scene_xdg_surface_create(parent_tree, xdg_surface);
|
|
|
|
|
wl_list_insert(&popups, &tp->link);
|
|
|
|
|
/* wlr_scene_xdg_surface_create destroys the node when the surface is
|
|
|
|
|
destroyed, so we only track positioning/reposition listeners here. */
|
|
|
|
|
tp->commit.notify = popup_handle_commit;
|
|
|
|
|
wl_signal_add(&xdg_surface->surface->events.commit, &tp->commit);
|
|
|
|
|
tp->destroy.notify = popup_handle_destroy;
|
|
|
|
|
/* Listen on the popup's own destroy: wlroots emits it (then asserts its
|
|
|
|
|
event lists are empty) before the popup is freed. */
|
|
|
|
|
wl_signal_add(&popup->events.destroy, &tp->destroy);
|
|
|
|
|
tp->reposition.notify = popup_handle_reposition;
|
|
|
|
|
wl_signal_add(&popup->events.reposition, &tp->reposition);
|
|
|
|
|
/* remember the scene tree so a submenu popup can find its parent's tree */
|
|
|
|
|
xdg_surface->data = tp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void handle_new_popup(struct wl_listener *listener, void *data) {
|
|
|
|
|
(void)listener;
|
|
|
|
|
struct wlr_xdg_popup *popup = data;
|
|
|
|
|
|
|
|
|
|
/* Popups position themselves relative to their immediate parent surface,
|
|
|
|
|
so place each popup under its parent's scene tree. */
|
|
|
|
|
struct wlr_scene_tree *parent_tree = NULL;
|
|
|
|
|
struct wlr_xdg_surface *parent =
|
|
|
|
|
wlr_xdg_surface_try_from_wlr_surface(popup->parent);
|
|
|
|
|
if (parent) {
|
|
|
|
|
if (parent->role == WLR_XDG_SURFACE_ROLE_TOPLEVEL) {
|
|
|
|
|
struct tabbed_app *app = app_for_toplevel(parent->toplevel);
|
|
|
|
|
if (app) parent_tree = app->tree;
|
|
|
|
|
} else if (parent->role == WLR_XDG_SURFACE_ROLE_POPUP) {
|
|
|
|
|
struct tabbed_popup *tp = parent->data;
|
|
|
|
|
if (tp) parent_tree = tp->scene;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!parent_tree) return;
|
|
|
|
|
create_popup(popup, parent_tree);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
static void set_active_surface(struct wlr_xdg_toplevel *toplevel) {
|
|
|
|
|
if (server.active == toplevel) return;
|
|
|
|
|
|
|
|
|
|
struct tabbed_app *old = app_for_toplevel(server.active);
|
|
|
|
|
if (old && old->tree)
|
|
|
|
|
wlr_scene_node_set_enabled(&old->tree->node, false);
|
|
|
|
|
if (server.active)
|
|
|
|
|
wlr_xdg_toplevel_set_activated(server.active, false);
|
|
|
|
|
|
|
|
|
|
server.active = toplevel;
|
|
|
|
|
wlr_xdg_toplevel_set_activated(toplevel, true);
|
|
|
|
|
|
|
|
|
|
struct tabbed_app *app = app_for_toplevel(server.active);
|
|
|
|
|
if (app && app->tree) {
|
2026-08-07 07:48:27 +10:00
|
|
|
position_toplevel(app);
|
2026-08-06 22:17:40 +10:00
|
|
|
wlr_scene_node_set_enabled(&app->tree->node, true);
|
2026-08-09 02:02:22 +10:00
|
|
|
if (tabbed_log_level() >= TABBED_LOG_DEBUG)
|
|
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d surface=%dx%d "
|
|
|
|
|
"node on\n", app_index(app),
|
|
|
|
|
toplevel->base->surface->current.width,
|
2026-08-09 01:22:01 +10:00
|
|
|
toplevel->base->surface->current.height);
|
2026-08-09 02:02:22 +10:00
|
|
|
} else if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
|
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d has NO scene node!\n",
|
2026-08-09 01:22:01 +10:00
|
|
|
app ? app_index(app) : -1);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wlr_seat_keyboard_notify_enter(server.seat, toplevel->base->surface,
|
|
|
|
|
server.keyboard.keycodes, server.keyboard.num_keycodes,
|
|
|
|
|
&server.keyboard.modifiers);
|
|
|
|
|
needs_redraw = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void cycle_tab(int dir) {
|
|
|
|
|
if (server.num_apps == 0) return;
|
|
|
|
|
int idx = -1;
|
|
|
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
|
|
|
if (server.apps[i]->toplevel == server.active) {
|
|
|
|
|
idx = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
for (int k = 1; k <= server.num_apps; k++) {
|
|
|
|
|
int j = ((idx < 0 ? 0 : idx) + k * dir + server.num_apps) % server.num_apps;
|
|
|
|
|
if (server.apps[j]->toplevel) {
|
|
|
|
|
set_active_surface(server.apps[j]->toplevel);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:53:32 +10:00
|
|
|
/* Fixed keybind repeat: first repeat after 600ms, then every 40ms (25Hz). */
|
|
|
|
|
#define TABBED_REPEAT_DELAY_MS 600
|
|
|
|
|
#define TABBED_REPEAT_INTERVAL_MS 40
|
|
|
|
|
|
|
|
|
|
static void kbd_repeat_cancel(void) {
|
|
|
|
|
if (host.repeat_timer) {
|
|
|
|
|
wl_event_source_remove(host.repeat_timer);
|
|
|
|
|
host.repeat_timer = NULL;
|
|
|
|
|
}
|
|
|
|
|
host.repeat_key = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static int kbd_repeat_cb(void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
if (host.repeat_key == 0)
|
|
|
|
|
return 0;
|
|
|
|
|
cycle_tab(host.repeat_dir);
|
|
|
|
|
if (host.repeat_timer)
|
|
|
|
|
wl_event_source_timer_update(host.repeat_timer,
|
|
|
|
|
TABBED_REPEAT_INTERVAL_MS);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void kbd_repeat_start(int key, int dir) {
|
|
|
|
|
kbd_repeat_cancel();
|
|
|
|
|
host.repeat_key = key;
|
|
|
|
|
host.repeat_dir = dir;
|
|
|
|
|
host.repeat_timer = wl_event_loop_add_timer(server.loop, kbd_repeat_cb, NULL);
|
|
|
|
|
if (host.repeat_timer)
|
|
|
|
|
wl_event_source_timer_update(host.repeat_timer, TABBED_REPEAT_DELAY_MS);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 11:29:07 +10:00
|
|
|
/* ===================== Clipboard bridge implementation ===================== */
|
|
|
|
|
|
|
|
|
|
static void bridge_mimes_reset(void) {
|
|
|
|
|
for (size_t i = 0; i < bridge.mimes_len; i++)
|
|
|
|
|
free(bridge.mimes[i]);
|
|
|
|
|
bridge.mimes_len = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_mimes_append(const char *mime) {
|
|
|
|
|
if (bridge.mimes_len == bridge.mimes_cap) {
|
|
|
|
|
size_t ncap = bridge.mimes_cap ? bridge.mimes_cap * 2 : 8;
|
|
|
|
|
char **nm = realloc(bridge.mimes, ncap * sizeof(*nm));
|
|
|
|
|
if (!nm)
|
|
|
|
|
return;
|
|
|
|
|
bridge.mimes = nm;
|
|
|
|
|
bridge.mimes_cap = ncap;
|
|
|
|
|
}
|
|
|
|
|
bridge.mimes[bridge.mimes_len++] = strdup(mime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_inner_src_destroy(struct wl_listener *l, void *data);
|
|
|
|
|
static bool bridge_prefetch_mime_compatible(const char *mime);
|
|
|
|
|
static void bridge_prefetch_serve(const char *mime, int32_t fd);
|
|
|
|
|
|
|
|
|
|
/* Compositor-side nested source: when a nested client pastes, wlroots calls
|
|
|
|
|
our impl->send() and we pull the data from the host offer straight into the
|
|
|
|
|
fd wlroots gave us (zero copy, no relay). */
|
|
|
|
|
static void bridge_inner_source_send(struct wlr_data_source *src,
|
|
|
|
|
const char *mime, int32_t fd) {
|
|
|
|
|
(void)src;
|
|
|
|
|
if (bridge_prefetch_mime_compatible(mime)) {
|
|
|
|
|
bridge_prefetch_serve(mime, fd);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_offer) {
|
|
|
|
|
zwlr_data_control_offer_v1_receive(bridge.dc_offer, mime, fd);
|
|
|
|
|
close(fd);
|
|
|
|
|
} else if (bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE &&
|
|
|
|
|
bridge.dd_offer) {
|
|
|
|
|
wl_data_offer_receive(bridge.dd_offer, mime, fd);
|
|
|
|
|
close(fd);
|
|
|
|
|
} else {
|
|
|
|
|
close(fd);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_inner_source_destroy(struct wlr_data_source *src) {
|
|
|
|
|
free(src);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wlr_data_source_impl bridge_inner_source_impl = {
|
|
|
|
|
.send = bridge_inner_source_send,
|
|
|
|
|
.destroy = bridge_inner_source_destroy,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void bridge_set_inner_src(struct wlr_data_source *src) {
|
|
|
|
|
if (bridge.inner_src == src)
|
|
|
|
|
return;
|
|
|
|
|
if (bridge.inner_src) {
|
|
|
|
|
wl_list_remove(&bridge.inner_src_destroy.link);
|
|
|
|
|
bridge.inner_src = NULL;
|
|
|
|
|
}
|
|
|
|
|
bridge.inner_src = src;
|
|
|
|
|
if (src) {
|
|
|
|
|
bridge.inner_src_destroy.notify = bridge_inner_src_destroy;
|
|
|
|
|
wl_signal_add(&src->events.destroy, &bridge.inner_src_destroy);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_inner_src_destroy(struct wl_listener *l, void *data) {
|
|
|
|
|
(void)data;
|
|
|
|
|
wl_list_remove(&l->link);
|
|
|
|
|
bridge.inner_src = NULL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Put a host selection into the nested seat. The source stays valid for the
|
|
|
|
|
whole lifetime of the host offer, so pastes can be served asynchronously. */
|
|
|
|
|
static void bridge_mirror_to_nested(void) {
|
|
|
|
|
struct wlr_data_source *src = calloc(1, sizeof(*src));
|
|
|
|
|
if (!src) {
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
wlr_data_source_init(src, &bridge_inner_source_impl);
|
|
|
|
|
for (size_t i = 0; i < bridge.mimes_len; i++) {
|
|
|
|
|
char **p = wl_array_add(&src->mime_types, sizeof(char *));
|
|
|
|
|
if (!p) {
|
|
|
|
|
for (size_t j = i; j < bridge.mimes_len; j++)
|
|
|
|
|
free(bridge.mimes[j]);
|
|
|
|
|
bridge.mimes_len = 0;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
*p = bridge.mimes[i];
|
|
|
|
|
bridge.mimes[i] = NULL; /* ownership moves into the wl_array */
|
|
|
|
|
}
|
|
|
|
|
bridge.mimes_len = 0;
|
|
|
|
|
bridge_set_inner_src(src);
|
|
|
|
|
wlr_seat_set_selection(server.seat, src,
|
|
|
|
|
wl_display_next_serial(server.wl_display));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_clear_nested(void) {
|
|
|
|
|
if (!server.seat->selection_source)
|
|
|
|
|
return;
|
|
|
|
|
bridge.ignore_next_inner = true;
|
|
|
|
|
wlr_seat_set_selection(server.seat, NULL,
|
|
|
|
|
wl_display_next_serial(server.wl_display));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------- text prefetch: serve pastes from a buffered copy -------- */
|
|
|
|
|
|
|
|
|
|
#define TABBED_PREFETCH_MAX (16 * 1024 * 1024)
|
|
|
|
|
|
|
|
|
|
static void bridge_prefetch_cancel(void) {
|
|
|
|
|
if (bridge.prefetch_src) {
|
|
|
|
|
wl_event_source_remove(bridge.prefetch_src);
|
|
|
|
|
bridge.prefetch_src = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.prefetch_fd >= 0) {
|
|
|
|
|
close(bridge.prefetch_fd);
|
|
|
|
|
bridge.prefetch_fd = -1;
|
|
|
|
|
}
|
|
|
|
|
free(bridge.prefetch_buf);
|
|
|
|
|
bridge.prefetch_buf = NULL;
|
|
|
|
|
bridge.prefetch_len = bridge.prefetch_cap = 0;
|
|
|
|
|
bridge.prefetch_mime[0] = '\0';
|
|
|
|
|
bridge.prefetch_ready = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Drain the prefetch pipe into the buffer. Returns true when the stream is
|
|
|
|
|
finished (EOF/error) and the buffer should be kept as the paste payload. */
|
|
|
|
|
static bool bridge_prefetch_drain(int fd) {
|
|
|
|
|
for (;;) {
|
|
|
|
|
size_t room = bridge.prefetch_cap - bridge.prefetch_len;
|
|
|
|
|
if (bridge.prefetch_len + 4096 > bridge.prefetch_cap &&
|
|
|
|
|
bridge.prefetch_cap < TABBED_PREFETCH_MAX) {
|
|
|
|
|
size_t ncap = bridge.prefetch_cap ? bridge.prefetch_cap * 2 : 8192;
|
|
|
|
|
if (ncap > TABBED_PREFETCH_MAX)
|
|
|
|
|
ncap = TABBED_PREFETCH_MAX;
|
|
|
|
|
char *nb = realloc(bridge.prefetch_buf, ncap);
|
|
|
|
|
if (nb) {
|
|
|
|
|
bridge.prefetch_buf = nb;
|
|
|
|
|
bridge.prefetch_cap = ncap;
|
|
|
|
|
room = bridge.prefetch_cap - bridge.prefetch_len;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (room == 0) {
|
|
|
|
|
/* cap reached: drain without storing to keep the writer unblocked */
|
|
|
|
|
char tmp;
|
|
|
|
|
ssize_t n = read(fd, &tmp, 1);
|
|
|
|
|
if (n > 0)
|
|
|
|
|
continue;
|
|
|
|
|
if (n == 0)
|
|
|
|
|
return true;
|
|
|
|
|
if (errno == EINTR)
|
|
|
|
|
continue;
|
|
|
|
|
if (errno == EAGAIN)
|
|
|
|
|
return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
ssize_t n = read(fd, bridge.prefetch_buf + bridge.prefetch_len, room);
|
|
|
|
|
if (n > 0) {
|
|
|
|
|
bridge.prefetch_len += (size_t)n;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (n == 0)
|
|
|
|
|
return true; /* EOF */
|
|
|
|
|
if (errno == EINTR)
|
|
|
|
|
continue;
|
|
|
|
|
if (errno == EAGAIN)
|
|
|
|
|
return false; /* more data pending */
|
|
|
|
|
return true; /* read error: keep whatever we got */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static int bridge_prefetch_cb(int fd, uint32_t mask, void *data) {
|
|
|
|
|
(void)data; (void)mask;
|
|
|
|
|
if (!bridge_prefetch_drain(fd))
|
|
|
|
|
return 0;
|
|
|
|
|
if (bridge.prefetch_src) {
|
|
|
|
|
wl_event_source_remove(bridge.prefetch_src);
|
|
|
|
|
bridge.prefetch_src = NULL;
|
|
|
|
|
}
|
|
|
|
|
close(fd);
|
|
|
|
|
bridge.prefetch_fd = -1;
|
|
|
|
|
bridge.prefetch_ready = true;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Pick the best text MIME offered for prefetching. */
|
|
|
|
|
static const char *bridge_pick_text_mime(void) {
|
|
|
|
|
static const char *pref[] = {
|
|
|
|
|
"text/plain;charset=utf-8", "text/plain", "UTF8_STRING", NULL,
|
|
|
|
|
};
|
|
|
|
|
for (int i = 0; pref[i]; i++)
|
|
|
|
|
for (size_t j = 0; j < bridge.mimes_len; j++)
|
|
|
|
|
if (strcmp(bridge.mimes[j], pref[i]) == 0)
|
|
|
|
|
return bridge.mimes[j];
|
|
|
|
|
for (size_t j = 0; j < bridge.mimes_len; j++)
|
|
|
|
|
if (strncmp(bridge.mimes[j], "text/", 5) == 0)
|
|
|
|
|
return bridge.mimes[j];
|
|
|
|
|
return NULL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Start pulling the current host offer's primary text MIME into our buffer.
|
|
|
|
|
Must be called while bridge.mimes holds the freshly collected list. */
|
|
|
|
|
static void bridge_prefetch_start(void) {
|
|
|
|
|
bridge_prefetch_cancel();
|
|
|
|
|
if (!bridge.mimes_len)
|
|
|
|
|
return;
|
|
|
|
|
const char *mime = bridge_pick_text_mime();
|
|
|
|
|
if (!mime)
|
|
|
|
|
return;
|
|
|
|
|
int p[2];
|
|
|
|
|
if (pipe2(p, O_CLOEXEC | O_NONBLOCK) != 0)
|
|
|
|
|
return;
|
|
|
|
|
bridge.prefetch_fd = p[0];
|
|
|
|
|
bridge.prefetch_src = wl_event_loop_add_fd(server.loop, p[0],
|
|
|
|
|
WL_EVENT_READABLE, bridge_prefetch_cb, NULL);
|
|
|
|
|
if (!bridge.prefetch_src) {
|
|
|
|
|
close(p[0]);
|
|
|
|
|
close(p[1]);
|
|
|
|
|
bridge.prefetch_fd = -1;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
snprintf(bridge.prefetch_mime, sizeof(bridge.prefetch_mime), "%s", mime);
|
|
|
|
|
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_offer) {
|
|
|
|
|
zwlr_data_control_offer_v1_receive(bridge.dc_offer, mime, p[1]);
|
|
|
|
|
close(p[1]);
|
|
|
|
|
} else if (bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE &&
|
|
|
|
|
bridge.dd_offer) {
|
|
|
|
|
wl_data_offer_receive(bridge.dd_offer, mime, p[1]);
|
|
|
|
|
close(p[1]);
|
|
|
|
|
} else {
|
|
|
|
|
bridge_prefetch_cancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* The requested MIME can be served from the prefetched plain-text buffer. */
|
|
|
|
|
static bool bridge_prefetch_mime_compatible(const char *mime) {
|
|
|
|
|
if (!bridge.prefetch_ready || bridge.prefetch_mime[0] == '\0')
|
|
|
|
|
return false;
|
|
|
|
|
if (strcmp(mime, bridge.prefetch_mime) == 0)
|
|
|
|
|
return true;
|
|
|
|
|
bool a = strncmp(mime, "text/plain", 10) == 0 ||
|
|
|
|
|
strcmp(mime, "UTF8_STRING") == 0;
|
|
|
|
|
bool b = strncmp(bridge.prefetch_mime, "text/plain", 10) == 0 ||
|
|
|
|
|
strcmp(bridge.prefetch_mime, "UTF8_STRING") == 0;
|
|
|
|
|
return a && b;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_prefetch_serve(const char *mime, int32_t fd) {
|
|
|
|
|
if (!bridge_prefetch_mime_compatible(mime)) {
|
|
|
|
|
close(fd);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
size_t off = 0;
|
|
|
|
|
while (off < bridge.prefetch_len) {
|
|
|
|
|
ssize_t n = write(fd, bridge.prefetch_buf + off,
|
|
|
|
|
bridge.prefetch_len - off);
|
|
|
|
|
if (n > 0) {
|
|
|
|
|
off += (size_t)n;
|
|
|
|
|
} else if (n < 0 && (errno == EAGAIN || errno == EINTR)) {
|
|
|
|
|
continue;
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
close(fd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------- host backend: wlr-data-control ---------------- */
|
|
|
|
|
|
|
|
|
|
static void bridge_dc_source_send(void *data,
|
|
|
|
|
struct zwlr_data_control_source_v1 *src, const char *mime, int32_t fd) {
|
|
|
|
|
(void)data; (void)src;
|
|
|
|
|
if (bridge.inner_src)
|
|
|
|
|
wlr_data_source_send(bridge.inner_src, mime, fd);
|
|
|
|
|
else
|
|
|
|
|
close(fd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_dc_source_cancelled(void *data,
|
|
|
|
|
struct zwlr_data_control_source_v1 *src) {
|
|
|
|
|
(void)data;
|
|
|
|
|
zwlr_data_control_source_v1_destroy(src);
|
|
|
|
|
bridge.dc_source = NULL;
|
2026-08-10 19:10:58 +10:00
|
|
|
bridge.dc_pending_echo = false;
|
|
|
|
|
bridge.dd_pending_echo = false;
|
2026-08-10 11:29:07 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct zwlr_data_control_source_v1_listener bridge_dc_source_listener = {
|
|
|
|
|
.send = bridge_dc_source_send,
|
|
|
|
|
.cancelled = bridge_dc_source_cancelled,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void bridge_dc_offer_mime(void *data,
|
|
|
|
|
struct zwlr_data_control_offer_v1 *offer, const char *mime) {
|
|
|
|
|
(void)data; (void)offer;
|
|
|
|
|
bridge_mimes_append(mime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct zwlr_data_control_offer_v1_listener bridge_dc_offer_listener = {
|
|
|
|
|
.offer = bridge_dc_offer_mime,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void bridge_dc_device_finished(void *data,
|
|
|
|
|
struct zwlr_data_control_device_v1 *dev) {
|
|
|
|
|
(void)data; (void)dev;
|
|
|
|
|
bridge.backend = bridge.dd ? BRIDGE_BACKEND_WL_DATA_DEVICE
|
|
|
|
|
: BRIDGE_BACKEND_NONE;
|
|
|
|
|
if (bridge.dc_offer) {
|
|
|
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_offer);
|
|
|
|
|
bridge.dc_offer = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.dc_device) {
|
|
|
|
|
zwlr_data_control_device_v1_destroy(bridge.dc_device);
|
|
|
|
|
bridge.dc_device = NULL;
|
|
|
|
|
}
|
|
|
|
|
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: host data-control "
|
|
|
|
|
"finished; falling back to %s\n",
|
|
|
|
|
bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE ?
|
|
|
|
|
"focus-based wl_data_device" : "none");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_dc_device_data_offer(void *data,
|
|
|
|
|
struct zwlr_data_control_device_v1 *dev,
|
|
|
|
|
struct zwlr_data_control_offer_v1 *offer) {
|
|
|
|
|
(void)data; (void)dev;
|
2026-08-10 19:10:58 +10:00
|
|
|
if (bridge.dc_pending_echo) {
|
|
|
|
|
bridge.dc_pending_echo = false;
|
2026-08-10 11:29:07 +10:00
|
|
|
bridge.dc_echo_offer = offer;
|
|
|
|
|
}
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
zwlr_data_control_offer_v1_add_listener(offer, &bridge_dc_offer_listener, NULL);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_dc_device_selection(void *data,
|
|
|
|
|
struct zwlr_data_control_device_v1 *dev,
|
|
|
|
|
struct zwlr_data_control_offer_v1 *offer) {
|
|
|
|
|
(void)data; (void)dev;
|
|
|
|
|
bridge_prefetch_cancel();
|
|
|
|
|
if (offer && offer == bridge.dc_echo_offer) {
|
|
|
|
|
bridge.dc_echo_offer = NULL;
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
if (offer)
|
|
|
|
|
zwlr_data_control_offer_v1_destroy(offer);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-10 19:10:58 +10:00
|
|
|
bridge.dc_pending_echo = false;
|
|
|
|
|
bridge.dd_pending_echo = false;
|
2026-08-10 11:29:07 +10:00
|
|
|
if (bridge.dc_offer)
|
|
|
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_offer);
|
|
|
|
|
bridge.dc_offer = offer;
|
|
|
|
|
if (!offer) {
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
bridge_clear_nested();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bridge_prefetch_start();
|
|
|
|
|
bridge_mirror_to_nested();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 19:10:58 +10:00
|
|
|
/* Primary selection is intentionally not bridged; the offer must still be
|
|
|
|
|
acknowledged (NULL handler would make libwayland abort on the event) and
|
|
|
|
|
destroyed so it does not leak. */
|
|
|
|
|
static void bridge_dc_device_primary_selection(void *data,
|
|
|
|
|
struct zwlr_data_control_device_v1 *dev,
|
|
|
|
|
struct zwlr_data_control_offer_v1 *offer) {
|
|
|
|
|
(void)data; (void)dev;
|
|
|
|
|
if (offer)
|
|
|
|
|
zwlr_data_control_offer_v1_destroy(offer);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 11:29:07 +10:00
|
|
|
static const struct zwlr_data_control_device_v1_listener bridge_dc_listener = {
|
|
|
|
|
.data_offer = bridge_dc_device_data_offer,
|
|
|
|
|
.selection = bridge_dc_device_selection,
|
2026-08-10 19:10:58 +10:00
|
|
|
.primary_selection = bridge_dc_device_primary_selection,
|
2026-08-10 11:29:07 +10:00
|
|
|
.finished = bridge_dc_device_finished,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------------- host backend: wl_data_device (focus-based) ---------------- */
|
|
|
|
|
|
|
|
|
|
static void bridge_dd_source_send(void *data, struct wl_data_source *src,
|
|
|
|
|
const char *mime, int32_t fd) {
|
|
|
|
|
(void)data; (void)src;
|
|
|
|
|
if (bridge.inner_src)
|
|
|
|
|
wlr_data_source_send(bridge.inner_src, mime, fd);
|
|
|
|
|
else
|
|
|
|
|
close(fd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_dd_source_cancelled(void *data, struct wl_data_source *src) {
|
|
|
|
|
(void)data;
|
|
|
|
|
wl_data_source_destroy(src);
|
|
|
|
|
bridge.dd_source = NULL;
|
2026-08-10 19:10:58 +10:00
|
|
|
bridge.dc_pending_echo = false;
|
|
|
|
|
bridge.dd_pending_echo = false;
|
2026-08-10 11:29:07 +10:00
|
|
|
bridge.host_set_pending = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wl_data_source_listener bridge_dd_source_listener = {
|
|
|
|
|
.send = bridge_dd_source_send,
|
|
|
|
|
.cancelled = bridge_dd_source_cancelled,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void bridge_dd_offer_mime(void *data, struct wl_data_offer *offer,
|
|
|
|
|
const char *mime) {
|
|
|
|
|
(void)data; (void)offer;
|
|
|
|
|
bridge_mimes_append(mime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wl_data_offer_listener bridge_dd_offer_listener = {
|
|
|
|
|
.offer = bridge_dd_offer_mime,
|
|
|
|
|
/* source_actions / action left unset: drag & drop is not bridged */
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void bridge_dd_data_offer(void *data, struct wl_data_device *dev,
|
|
|
|
|
struct wl_data_offer *offer) {
|
|
|
|
|
(void)data; (void)dev;
|
2026-08-10 19:10:58 +10:00
|
|
|
if (bridge.dd_pending_echo) {
|
|
|
|
|
bridge.dd_pending_echo = false;
|
2026-08-10 11:29:07 +10:00
|
|
|
bridge.dd_echo_offer = offer;
|
|
|
|
|
}
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
wl_data_offer_add_listener(offer, &bridge_dd_offer_listener, NULL);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_dd_selection(void *data, struct wl_data_device *dev,
|
|
|
|
|
struct wl_data_offer *offer) {
|
|
|
|
|
(void)data; (void)dev;
|
|
|
|
|
bridge_prefetch_cancel();
|
|
|
|
|
if (offer && offer == bridge.dd_echo_offer) {
|
|
|
|
|
bridge.dd_echo_offer = NULL;
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
if (offer)
|
|
|
|
|
wl_data_offer_destroy(offer);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-10 19:10:58 +10:00
|
|
|
bridge.dc_pending_echo = false;
|
|
|
|
|
bridge.dd_pending_echo = false;
|
2026-08-10 11:29:07 +10:00
|
|
|
if (bridge.dd_offer)
|
|
|
|
|
wl_data_offer_destroy(bridge.dd_offer);
|
|
|
|
|
bridge.dd_offer = offer;
|
|
|
|
|
if (!offer) {
|
|
|
|
|
/* selection cleared, or we lost keyboard focus: keep the last-known
|
|
|
|
|
nested selection rather than emptying it on every focus change */
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bridge_prefetch_start();
|
|
|
|
|
bridge_mirror_to_nested();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wl_data_device_listener bridge_dd_listener = {
|
|
|
|
|
.data_offer = bridge_dd_data_offer,
|
|
|
|
|
.selection = bridge_dd_selection,
|
|
|
|
|
/* enter/leave/motion/drop left unset: drag & drop is not bridged */
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------------- nested seat side ---------------- */
|
|
|
|
|
|
|
|
|
|
static void bridge_seat_request_set_selection(struct wl_listener *l, void *data) {
|
|
|
|
|
(void)l;
|
|
|
|
|
struct wlr_seat_request_set_selection_event *event = data;
|
|
|
|
|
bridge.inner_clear_requested = (event->source == NULL);
|
|
|
|
|
wlr_seat_set_selection(server.seat, event->source, event->serial);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_host_set_selection(struct wlr_data_source *src) {
|
|
|
|
|
bridge_set_inner_src(src);
|
|
|
|
|
|
|
|
|
|
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_device) {
|
|
|
|
|
if (bridge.dc_source) {
|
|
|
|
|
zwlr_data_control_source_v1_destroy(bridge.dc_source);
|
|
|
|
|
bridge.dc_source = NULL;
|
|
|
|
|
}
|
|
|
|
|
struct zwlr_data_control_source_v1 *s =
|
|
|
|
|
zwlr_data_control_manager_v1_create_data_source(bridge.dc_manager);
|
|
|
|
|
char **p;
|
|
|
|
|
wl_array_for_each(p, &src->mime_types)
|
|
|
|
|
zwlr_data_control_source_v1_offer(s, *p);
|
|
|
|
|
zwlr_data_control_source_v1_add_listener(s, &bridge_dc_source_listener, NULL);
|
|
|
|
|
bridge.dc_source = s;
|
2026-08-10 19:10:58 +10:00
|
|
|
bridge.dc_pending_echo = true;
|
|
|
|
|
bridge.dd_pending_echo = true;
|
2026-08-10 11:29:07 +10:00
|
|
|
zwlr_data_control_device_v1_set_selection(bridge.dc_device, s);
|
|
|
|
|
bridge.host_set_pending = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE && bridge.dd) {
|
|
|
|
|
if (bridge.dd_source) {
|
|
|
|
|
wl_data_source_destroy(bridge.dd_source);
|
|
|
|
|
bridge.dd_source = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (!bridge.kbd_serial) {
|
|
|
|
|
bridge.host_set_pending = true;
|
|
|
|
|
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: no host keyboard "
|
|
|
|
|
"serial yet; deferring host set_selection until focus\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
struct wl_data_source *s =
|
|
|
|
|
wl_data_device_manager_create_data_source(bridge.ddm);
|
|
|
|
|
char **p;
|
|
|
|
|
wl_array_for_each(p, &src->mime_types)
|
|
|
|
|
wl_data_source_offer(s, *p);
|
|
|
|
|
wl_data_source_add_listener(s, &bridge_dd_source_listener, NULL);
|
|
|
|
|
bridge.dd_source = s;
|
2026-08-10 19:10:58 +10:00
|
|
|
bridge.dc_pending_echo = true;
|
|
|
|
|
bridge.dd_pending_echo = true;
|
2026-08-10 11:29:07 +10:00
|
|
|
wl_data_device_set_selection(bridge.dd, s, bridge.kbd_serial);
|
|
|
|
|
bridge.host_set_pending = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_host_clear(void) {
|
|
|
|
|
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_device)
|
|
|
|
|
zwlr_data_control_device_v1_set_selection(bridge.dc_device, NULL);
|
|
|
|
|
/* focus-based backend: nothing reliable to clear without focus/serial */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_seat_set_selection(struct wl_listener *l, void *data) {
|
|
|
|
|
(void)l; (void)data;
|
|
|
|
|
struct wlr_data_source *src = server.seat->selection_source;
|
|
|
|
|
if (src && src->impl == &bridge_inner_source_impl)
|
|
|
|
|
return; /* our own host->nested mirror */
|
|
|
|
|
if (bridge.ignore_next_inner) {
|
|
|
|
|
bridge.ignore_next_inner = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!src) {
|
|
|
|
|
if (bridge.inner_clear_requested) {
|
|
|
|
|
bridge.inner_clear_requested = false;
|
|
|
|
|
bridge_host_clear();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bridge.inner_clear_requested = false;
|
|
|
|
|
bridge_host_set_selection(src);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------- lifecycle ---------------- */
|
|
|
|
|
|
|
|
|
|
static void bridge_attach_seat(void) {
|
|
|
|
|
bridge.request_set_selection.notify = bridge_seat_request_set_selection;
|
|
|
|
|
wl_signal_add(&server.seat->events.request_set_selection,
|
|
|
|
|
&bridge.request_set_selection);
|
|
|
|
|
bridge.set_selection.notify = bridge_seat_set_selection;
|
|
|
|
|
wl_signal_add(&server.seat->events.set_selection, &bridge.set_selection);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_init(void) {
|
|
|
|
|
if (!host.seat) {
|
|
|
|
|
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: no host seat; disabled\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.ddm) {
|
|
|
|
|
bridge.dd = wl_data_device_manager_get_data_device(bridge.ddm, host.seat);
|
|
|
|
|
wl_data_device_add_listener(bridge.dd, &bridge_dd_listener, NULL);
|
|
|
|
|
}
|
|
|
|
|
if (bridge.dc_manager) {
|
|
|
|
|
bridge.dc_device = zwlr_data_control_manager_v1_get_data_device(
|
|
|
|
|
bridge.dc_manager, host.seat);
|
|
|
|
|
zwlr_data_control_device_v1_add_listener(bridge.dc_device,
|
|
|
|
|
&bridge_dc_listener, NULL);
|
|
|
|
|
bridge.backend = BRIDGE_BACKEND_DATA_CONTROL;
|
|
|
|
|
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: host data-control backend\n");
|
|
|
|
|
} else if (bridge.dd) {
|
|
|
|
|
bridge.backend = BRIDGE_BACKEND_WL_DATA_DEVICE;
|
|
|
|
|
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: focus-based wl_data_device "
|
|
|
|
|
"backend (host lacks wlr-data-control; host->nested sync needs "
|
|
|
|
|
"focus)\n");
|
|
|
|
|
} else {
|
|
|
|
|
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: no host clipboard path\n");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void bridge_cleanup(void) {
|
|
|
|
|
if (bridge.inner_src) {
|
|
|
|
|
wl_list_remove(&bridge.inner_src_destroy.link);
|
|
|
|
|
bridge.inner_src = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.dc_source) {
|
|
|
|
|
zwlr_data_control_source_v1_destroy(bridge.dc_source);
|
|
|
|
|
bridge.dc_source = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.dd_source) {
|
|
|
|
|
wl_data_source_destroy(bridge.dd_source);
|
|
|
|
|
bridge.dd_source = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.dc_offer) {
|
|
|
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_offer);
|
|
|
|
|
bridge.dc_offer = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.dd_offer) {
|
|
|
|
|
wl_data_offer_destroy(bridge.dd_offer);
|
|
|
|
|
bridge.dd_offer = NULL;
|
|
|
|
|
}
|
|
|
|
|
if (bridge.dc_device) {
|
|
|
|
|
zwlr_data_control_device_v1_destroy(bridge.dc_device);
|
|
|
|
|
bridge.dc_device = NULL;
|
|
|
|
|
}
|
|
|
|
|
bridge_mimes_reset();
|
|
|
|
|
free(bridge.mimes);
|
|
|
|
|
bridge.mimes = NULL;
|
|
|
|
|
bridge.mimes_cap = 0;
|
|
|
|
|
bridge_prefetch_cancel();
|
|
|
|
|
bridge.backend = BRIDGE_BACKEND_NONE;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
/* ===================== Host-side listeners ===================== */
|
|
|
|
|
|
|
|
|
|
static void host_registry_global(void *data, struct wl_registry *registry,
|
|
|
|
|
uint32_t name, const char *interface,
|
|
|
|
|
uint32_t version)
|
|
|
|
|
{
|
|
|
|
|
(void)data;
|
|
|
|
|
if (strcmp(interface, wl_compositor_interface.name) == 0) {
|
|
|
|
|
host.compositor = wl_registry_bind(registry, name,
|
|
|
|
|
&wl_compositor_interface, version < 4 ? version : 4);
|
|
|
|
|
} else if (strcmp(interface, xdg_wm_base_interface.name) == 0) {
|
|
|
|
|
host.wm_base = wl_registry_bind(registry, name,
|
|
|
|
|
&xdg_wm_base_interface, version < 1 ? version : 1);
|
2026-08-07 10:32:11 +10:00
|
|
|
} else if (strcmp(interface, zxdg_decoration_manager_v1_interface.name) == 0) {
|
|
|
|
|
host.decoration = wl_registry_bind(registry, name,
|
|
|
|
|
&zxdg_decoration_manager_v1_interface, 1);
|
2026-08-06 22:17:40 +10:00
|
|
|
} else if (strcmp(interface, wl_seat_interface.name) == 0) {
|
|
|
|
|
host.seat = wl_registry_bind(registry, name,
|
|
|
|
|
&wl_seat_interface, version < 5 ? version : 5);
|
2026-08-10 11:29:07 +10:00
|
|
|
} else if (strcmp(interface, wl_data_device_manager_interface.name) == 0) {
|
|
|
|
|
bridge.ddm = wl_registry_bind(registry, name,
|
|
|
|
|
&wl_data_device_manager_interface, version < 3 ? version : 3);
|
|
|
|
|
} else if (strcmp(interface,
|
|
|
|
|
zwlr_data_control_manager_v1_interface.name) == 0) {
|
|
|
|
|
bridge.dc_manager = wl_registry_bind(registry, name,
|
|
|
|
|
&zwlr_data_control_manager_v1_interface, version < 2 ? version : 2);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_registry_global_remove(void *data, struct wl_registry *registry,
|
|
|
|
|
uint32_t name)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)registry; (void)name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wl_registry_listener host_registry_listener = {
|
|
|
|
|
.global = host_registry_global,
|
|
|
|
|
.global_remove = host_registry_global_remove,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void host_xdg_wm_ping(void *data, struct xdg_wm_base *wm, uint32_t serial) {
|
|
|
|
|
(void)data;
|
|
|
|
|
xdg_wm_base_pong(wm, serial);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct xdg_wm_base_listener host_xdg_wm_listener = {
|
|
|
|
|
.ping = host_xdg_wm_ping,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void host_xdg_surface_configure(void *data, struct xdg_surface *s,
|
|
|
|
|
uint32_t serial)
|
|
|
|
|
{
|
|
|
|
|
(void)data;
|
|
|
|
|
xdg_surface_ack_configure(s, serial);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct xdg_surface_listener host_xdg_surface_listener = {
|
|
|
|
|
.configure = host_xdg_surface_configure,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void host_resize(int width, int height) {
|
|
|
|
|
if (width < 50) width = 50;
|
|
|
|
|
if (height < 50) height = 50;
|
|
|
|
|
host.width = width;
|
|
|
|
|
host.height = height;
|
|
|
|
|
if (host.egl_window)
|
|
|
|
|
wl_egl_window_resize(host.egl_window, width, height, 0, 0);
|
|
|
|
|
if (host.egl_ok) {
|
|
|
|
|
eglMakeCurrent(host.egl_display, host.egl_surface, host.egl_surface,
|
|
|
|
|
host.egl_context);
|
|
|
|
|
}
|
|
|
|
|
output_resize(width, height);
|
|
|
|
|
needs_redraw = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_xdg_toplevel_configure(void *data, struct xdg_toplevel *t,
|
|
|
|
|
int32_t width, int32_t height,
|
|
|
|
|
struct wl_array *states)
|
|
|
|
|
{
|
2026-08-08 19:37:54 +10:00
|
|
|
(void)data; (void)t;
|
|
|
|
|
host.fullscreen = false;
|
|
|
|
|
uint32_t *state;
|
|
|
|
|
wl_array_for_each(state, states)
|
|
|
|
|
if (*state == XDG_TOPLEVEL_STATE_FULLSCREEN)
|
|
|
|
|
host.fullscreen = true;
|
2026-08-06 22:17:40 +10:00
|
|
|
if (width > 0 && height > 0)
|
|
|
|
|
host_resize(width, height);
|
|
|
|
|
host.configured = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_xdg_toplevel_close(void *data, struct xdg_toplevel *t) {
|
|
|
|
|
(void)data; (void)t;
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: window closed\n");
|
2026-08-07 21:23:01 +10:00
|
|
|
shutdown_cleanup();
|
2026-08-06 22:17:40 +10:00
|
|
|
exit(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct xdg_toplevel_listener host_xdg_toplevel_listener = {
|
|
|
|
|
.configure = host_xdg_toplevel_configure,
|
|
|
|
|
.close = host_xdg_toplevel_close,
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-07 10:32:11 +10:00
|
|
|
static void host_decoration_configure(void *data,
|
|
|
|
|
struct zxdg_toplevel_decoration_v1 *dec,
|
|
|
|
|
uint32_t mode)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)dec;
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: host decoration mode=%u (%s)\n",
|
|
|
|
|
mode,
|
2026-08-07 10:32:11 +10:00
|
|
|
mode == ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE
|
|
|
|
|
? "server-side" : "client-side");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct zxdg_toplevel_decoration_v1_listener host_decoration_listener = {
|
|
|
|
|
.configure = host_decoration_configure,
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
/* ---------- host keyboard ---------- */
|
|
|
|
|
|
|
|
|
|
static void host_kbd_keymap(void *data, struct wl_keyboard *kbd, uint32_t format,
|
|
|
|
|
int32_t fd, uint32_t size)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)kbd;
|
2026-08-09 02:28:36 +10:00
|
|
|
if (format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1 || size == 0 || !server.xkb_ctx) {
|
|
|
|
|
close(fd);
|
2026-08-06 22:17:40 +10:00
|
|
|
return;
|
|
|
|
|
}
|
2026-08-09 02:28:36 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "tabbed-wlroots: host keymap: format=%u size=%u\n",
|
|
|
|
|
format, size);
|
|
|
|
|
|
|
|
|
|
/* The keymap is an shm file (size includes the trailing NUL), so mmap it
|
|
|
|
|
like wlroots' own clients do; fall back to a plain read loop. */
|
|
|
|
|
char *buf = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
|
|
|
|
|
bool mapped = buf != MAP_FAILED;
|
|
|
|
|
size_t total = mapped ? size : 0;
|
|
|
|
|
if (!mapped) {
|
|
|
|
|
buf = malloc(size + 1);
|
|
|
|
|
if (!buf) {
|
|
|
|
|
close(fd);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
while (total < size) {
|
|
|
|
|
ssize_t rd = read(fd, buf + total, size - total);
|
|
|
|
|
if (rd > 0) {
|
|
|
|
|
total += rd;
|
|
|
|
|
} else if (rd < 0 && (errno == EAGAIN || errno == EINTR)) {
|
|
|
|
|
struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 };
|
|
|
|
|
nanosleep(&ts, NULL);
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
2026-08-09 02:28:36 +10:00
|
|
|
}
|
|
|
|
|
buf[total] = '\0';
|
|
|
|
|
if (total < size)
|
|
|
|
|
tabbed_log(TABBED_LOG_DEBUG,
|
|
|
|
|
"tabbed-wlroots: host keymap: short read %zu/%u bytes\n",
|
|
|
|
|
total, size);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct xkb_keymap *km = xkb_keymap_new_from_string(
|
|
|
|
|
server.xkb_ctx, buf, XKB_KEYMAP_FORMAT_TEXT_V1, 0);
|
|
|
|
|
if (km) {
|
|
|
|
|
wlr_keyboard_set_keymap(&server.keyboard, km);
|
|
|
|
|
if (server.xkb_keymap)
|
|
|
|
|
xkb_keymap_unref(server.xkb_keymap);
|
|
|
|
|
server.xkb_keymap = km;
|
|
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "tabbed-wlroots: host keymap parsed OK\n");
|
|
|
|
|
} else {
|
|
|
|
|
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: failed to parse host "
|
|
|
|
|
"keymap (%u bytes); keeping fallback keymap\n", size);
|
|
|
|
|
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
|
|
|
|
char hex[129];
|
|
|
|
|
size_t n = total < 64 ? total : 64;
|
|
|
|
|
for (size_t i = 0; i < n; i++)
|
|
|
|
|
snprintf(hex + i * 2, sizeof(hex) - i * 2, "%02x",
|
|
|
|
|
(unsigned char)buf[i]);
|
|
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "tabbed-wlroots: keymap head (%zu/%u "
|
|
|
|
|
"bytes): %s\n", n, size, hex);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 02:28:36 +10:00
|
|
|
|
|
|
|
|
if (mapped)
|
|
|
|
|
munmap(buf, size);
|
|
|
|
|
else
|
|
|
|
|
free(buf);
|
|
|
|
|
close(fd);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_kbd_enter(void *data, struct wl_keyboard *kbd, uint32_t serial,
|
|
|
|
|
struct wl_surface *surface, struct wl_array *keys)
|
|
|
|
|
{
|
2026-08-10 11:29:07 +10:00
|
|
|
(void)data; (void)kbd; (void)surface; (void)keys;
|
|
|
|
|
/* Remember the enter serial for host clipboard set_selection, and drop any
|
|
|
|
|
stale echo guard from a set_selection made while unfocused. */
|
|
|
|
|
bridge.kbd_serial = serial;
|
2026-08-10 19:10:58 +10:00
|
|
|
bridge.dc_pending_echo = false;
|
|
|
|
|
bridge.dd_pending_echo = false;
|
2026-08-10 11:29:07 +10:00
|
|
|
if (bridge.host_set_pending && server.seat->selection_source &&
|
|
|
|
|
server.seat->selection_source->impl != &bridge_inner_source_impl) {
|
|
|
|
|
bridge.host_set_pending = false;
|
|
|
|
|
bridge_host_set_selection(server.seat->selection_source);
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
if (server.active)
|
|
|
|
|
wlr_seat_keyboard_notify_enter(server.seat,
|
|
|
|
|
server.active->base->surface,
|
|
|
|
|
server.keyboard.keycodes, server.keyboard.num_keycodes,
|
|
|
|
|
&server.keyboard.modifiers);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_kbd_leave(void *data, struct wl_keyboard *kbd, uint32_t serial,
|
|
|
|
|
struct wl_surface *surface)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)kbd; (void)serial; (void)surface;
|
|
|
|
|
wlr_seat_keyboard_notify_clear_focus(server.seat);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 12:14:29 +10:00
|
|
|
/* Temporary grey-band diagnosis: dump the scene buffer (client content as
|
|
|
|
|
wlroots rendered it) to /tmp/scene_N.ppm. The buffer memory is top-down,
|
|
|
|
|
XRGB8888. */
|
|
|
|
|
static void dump_scene_buffer(struct wlr_buffer *buf) {
|
|
|
|
|
if (!buf) return;
|
|
|
|
|
void *data;
|
|
|
|
|
uint32_t format;
|
|
|
|
|
size_t stride;
|
|
|
|
|
if (!wlr_buffer_begin_data_ptr_access(buf, WLR_BUFFER_DATA_PTR_ACCESS_READ,
|
2026-08-09 01:22:01 +10:00
|
|
|
&data, &format, &stride)) {
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "[dump] scene buffer not CPU-readable "
|
|
|
|
|
"(%dx%d)\n", buf->width, buf->height);
|
2026-08-08 12:14:29 +10:00
|
|
|
return;
|
2026-08-09 01:22:01 +10:00
|
|
|
}
|
|
|
|
|
/* [diag] classify the buffer: count non-black pixels so we can tell
|
|
|
|
|
"client painted nothing" from "we failed to import". */
|
|
|
|
|
int w = buf->width, h = buf->height;
|
|
|
|
|
unsigned long nonblack = 0, lum = 0;
|
|
|
|
|
unsigned char *p = data;
|
|
|
|
|
for (int y = 0; y < h; y++) {
|
|
|
|
|
unsigned char *row = p + (size_t)y * stride;
|
|
|
|
|
for (int x = 0; x < w; x++) {
|
|
|
|
|
uint32_t b = row[x*4+0];
|
|
|
|
|
unsigned long v = b + row[x*4+1] + row[x*4+2];
|
|
|
|
|
lum += v;
|
|
|
|
|
if (v > 12) nonblack++;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "[dump] scene %dx%d fmt=0x%x "
|
|
|
|
|
"nonblack=%lu/%lu mean=%lu\n", w, h, format, nonblack,
|
|
|
|
|
(unsigned long)w * h, lum / ((unsigned long)w * h + 1));
|
2026-08-08 12:14:29 +10:00
|
|
|
char path[64];
|
|
|
|
|
snprintf(path, sizeof(path), "/tmp/scene_%d.ppm", dump_seq);
|
|
|
|
|
FILE *f = fopen(path, "wb");
|
|
|
|
|
if (f) {
|
|
|
|
|
int w = buf->width, h = buf->height;
|
|
|
|
|
fprintf(f, "P6\n%d %d\n255\n", w, h);
|
|
|
|
|
unsigned char *p = data;
|
|
|
|
|
for (int y = 0; y < h; y++) {
|
|
|
|
|
unsigned char *row = p + (size_t)y * stride;
|
|
|
|
|
for (int x = 0; x < w; x++) {
|
|
|
|
|
fputc(row[x*4+0], f);
|
|
|
|
|
fputc(row[x*4+1], f);
|
|
|
|
|
fputc(row[x*4+2], f);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fclose(f);
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "[dump] scene buffer -> %s (%dx%d, "
|
|
|
|
|
"fmt=0x%x)\n", path, w, h, format);
|
2026-08-08 12:14:29 +10:00
|
|
|
}
|
|
|
|
|
wlr_buffer_end_data_ptr_access(buf);
|
|
|
|
|
dump_seq++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Dump what we are about to present to the host (our composited back buffer).
|
|
|
|
|
Must be called with the host EGL context current and back buffer freshly
|
|
|
|
|
drawn. GL readback is bottom-up, so flip vertically. */
|
|
|
|
|
static void dump_host_back(void) {
|
|
|
|
|
int w = host.width, h = host.height;
|
|
|
|
|
unsigned char *px = malloc((size_t)w * h * 4);
|
|
|
|
|
if (!px) return;
|
|
|
|
|
glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, px);
|
2026-08-09 01:22:01 +10:00
|
|
|
unsigned long nonblack = 0, lum = 0;
|
|
|
|
|
for (int y = 0; y < h * w; y++) {
|
|
|
|
|
unsigned long v = px[y*4+0] + px[y*4+1] + px[y*4+2];
|
|
|
|
|
lum += v;
|
|
|
|
|
if (v > 12) nonblack++;
|
|
|
|
|
}
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "[dump] host %dx%d nonblack=%lu/%lu mean=%lu\n",
|
2026-08-09 01:22:01 +10:00
|
|
|
w, h, nonblack, (unsigned long)w * h,
|
|
|
|
|
lum / ((unsigned long)w * h + 1));
|
2026-08-08 12:14:29 +10:00
|
|
|
char path[64];
|
|
|
|
|
snprintf(path, sizeof(path), "/tmp/host_%d.ppm", dump_seq);
|
|
|
|
|
FILE *f = fopen(path, "wb");
|
|
|
|
|
if (f) {
|
|
|
|
|
fprintf(f, "P6\n%d %d\n255\n", w, h);
|
|
|
|
|
for (int y = h - 1; y >= 0; y--) {
|
|
|
|
|
unsigned char *row = px + (size_t)y * w * 4;
|
|
|
|
|
for (int x = 0; x < w; x++) {
|
|
|
|
|
fputc(row[x*4+0], f);
|
|
|
|
|
fputc(row[x*4+1], f);
|
|
|
|
|
fputc(row[x*4+2], f);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fclose(f);
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "[dump] host back buffer -> %s (%dx%d)\n",
|
|
|
|
|
path, w, h);
|
2026-08-08 12:14:29 +10:00
|
|
|
}
|
|
|
|
|
free(px);
|
|
|
|
|
dump_seq++;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
static void host_kbd_key(void *data, struct wl_keyboard *kbd, uint32_t serial,
|
|
|
|
|
uint32_t time, uint32_t key, uint32_t state)
|
|
|
|
|
{
|
2026-08-10 11:29:07 +10:00
|
|
|
(void)data; (void)kbd;
|
|
|
|
|
bridge.kbd_serial = serial;
|
2026-08-10 07:29:10 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG,
|
|
|
|
|
"[kbd] key=%u state=%u ctrl=%d repeat_key=%u\n", key, state,
|
|
|
|
|
(host.host_mods_depressed & (1u << 2)) != 0, host.repeat_key);
|
2026-08-08 19:37:54 +10:00
|
|
|
/* F11 toggles the host window fullscreen. */
|
|
|
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED && key == 87) {
|
|
|
|
|
host.fullscreen = !host.fullscreen;
|
2026-08-10 07:29:10 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "host fullscreen=%d\n", host.fullscreen);
|
2026-08-08 19:37:54 +10:00
|
|
|
if (host.fullscreen)
|
|
|
|
|
xdg_toplevel_set_fullscreen(host.toplevel, NULL);
|
|
|
|
|
else
|
|
|
|
|
xdg_toplevel_unset_fullscreen(host.toplevel);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-09 02:53:32 +10:00
|
|
|
/* Temporary: F9/F10 frame dumps for the grey-band diagnosis. */
|
2026-08-08 12:14:29 +10:00
|
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
2026-08-09 02:53:32 +10:00
|
|
|
if (key == 67) /* F9 */ {
|
2026-08-08 12:14:29 +10:00
|
|
|
dump_scene_req = true;
|
|
|
|
|
needs_redraw = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-09 02:53:32 +10:00
|
|
|
if (key == 68) /* F10 */ {
|
2026-08-08 12:14:29 +10:00
|
|
|
dump_host_req = true;
|
|
|
|
|
needs_redraw = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 02:53:32 +10:00
|
|
|
/* Ctrl+PageUp / Ctrl+PageDown cycle tabs. The host sends a single
|
|
|
|
|
physical press, so repeats are synthesized by kbd_repeat_start();
|
|
|
|
|
subsequent host presses of an already-held key are ignored so a
|
|
|
|
|
host-sent repeat never cycles twice. */
|
2026-08-06 22:17:40 +10:00
|
|
|
bool ctrl = (host.host_mods_depressed & (1u << 2)) != 0;
|
2026-08-09 02:53:32 +10:00
|
|
|
if (ctrl && (key == 104 || key == 109)) {
|
|
|
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
|
|
|
|
if (host.repeat_key != key) {
|
|
|
|
|
int dir = key == 109 ? 1 : -1;
|
|
|
|
|
cycle_tab(dir);
|
|
|
|
|
kbd_repeat_start(key, dir);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
kbd_repeat_cancel();
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
return;
|
|
|
|
|
}
|
2026-08-09 02:53:32 +10:00
|
|
|
/* any other key activity stops the tab-switch repeat */
|
|
|
|
|
kbd_repeat_cancel();
|
2026-08-06 22:17:40 +10:00
|
|
|
struct wlr_keyboard_key_event event = {
|
|
|
|
|
.time_msec = time,
|
|
|
|
|
.keycode = key,
|
|
|
|
|
.update_state = true,
|
|
|
|
|
.state = state,
|
|
|
|
|
};
|
|
|
|
|
wlr_keyboard_notify_key(&server.keyboard, &event);
|
|
|
|
|
wlr_seat_set_keyboard(server.seat, &server.keyboard);
|
|
|
|
|
wlr_seat_keyboard_notify_key(server.seat, time, key, state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_kbd_modifiers(void *data, struct wl_keyboard *kbd, uint32_t serial,
|
|
|
|
|
uint32_t mods_depressed, uint32_t mods_latched,
|
|
|
|
|
uint32_t mods_locked, uint32_t group)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)kbd; (void)serial;
|
|
|
|
|
host.host_mods_depressed = mods_depressed;
|
2026-08-09 02:53:32 +10:00
|
|
|
if (host.repeat_key && !(mods_depressed & (1u << 2)))
|
|
|
|
|
kbd_repeat_cancel();
|
2026-08-06 22:17:40 +10:00
|
|
|
wlr_keyboard_notify_modifiers(&server.keyboard, mods_depressed,
|
|
|
|
|
mods_latched, mods_locked, group);
|
|
|
|
|
wlr_seat_keyboard_notify_modifiers(server.seat, &server.keyboard.modifiers);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_kbd_repeat_info(void *data, struct wl_keyboard *kbd,
|
|
|
|
|
int32_t rate, int32_t delay)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)kbd;
|
|
|
|
|
wlr_keyboard_set_repeat_info(&server.keyboard, rate, delay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wl_keyboard_listener host_kbd_listener = {
|
|
|
|
|
.keymap = host_kbd_keymap,
|
|
|
|
|
.enter = host_kbd_enter,
|
|
|
|
|
.leave = host_kbd_leave,
|
|
|
|
|
.key = host_kbd_key,
|
|
|
|
|
.modifiers = host_kbd_modifiers,
|
|
|
|
|
.repeat_info = host_kbd_repeat_info,
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-10 07:29:10 +10:00
|
|
|
/* ============ stdin event feed (test/driver channel) ============
|
|
|
|
|
Reads raw keyboard events from stdin and dispatches them into the
|
|
|
|
|
same host_kbd_key / host_kbd_modifiers handlers a real host would
|
|
|
|
|
deliver, so the key handler itself stays under test. Two records:
|
|
|
|
|
|
|
|
|
|
1 | u32 key | u32 state (WL_KEYBOARD_KEY_STATE_*)
|
|
|
|
|
2 | u32 mods | u32 latched | u32 locked | u32 group
|
|
|
|
|
*/
|
|
|
|
|
#define CTL_CMD_KEY 1
|
|
|
|
|
#define CTL_CMD_MODS 2
|
|
|
|
|
|
|
|
|
|
#define CTL_MAX_MSG 20 /* 1 + 4 + 4 (or 1 + 16) after padding */
|
|
|
|
|
#define CTL_BUF_SIZE 512
|
|
|
|
|
|
|
|
|
|
static uint32_t ctl_serial;
|
|
|
|
|
static uint32_t ctl_time;
|
|
|
|
|
|
|
|
|
|
static void ctl_dispatch(const uint8_t *msg, size_t len) {
|
|
|
|
|
uint32_t u32[4];
|
|
|
|
|
if (len < 5) return;
|
|
|
|
|
switch (msg[0]) {
|
|
|
|
|
case CTL_CMD_KEY:
|
|
|
|
|
if (len < 9) return;
|
|
|
|
|
memcpy(u32, msg + 1, 8);
|
|
|
|
|
ctl_time += 1;
|
|
|
|
|
host_kbd_key(NULL, NULL, ++ctl_serial, ctl_time, u32[0], u32[1]);
|
|
|
|
|
break;
|
|
|
|
|
case CTL_CMD_MODS:
|
|
|
|
|
if (len < 17) return;
|
|
|
|
|
memcpy(u32, msg + 1, 16);
|
|
|
|
|
ctl_time += 1;
|
|
|
|
|
host_kbd_modifiers(NULL, NULL, ++ctl_serial, u32[0], u32[1],
|
|
|
|
|
u32[2], u32[3]);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static int ctl_cb(int fd, uint32_t mask, void *data) {
|
|
|
|
|
static uint8_t buf[CTL_BUF_SIZE];
|
|
|
|
|
static size_t have = 0;
|
|
|
|
|
(void)data;
|
|
|
|
|
|
|
|
|
|
/* stdin closed: stop watching but keep the compositor running */
|
|
|
|
|
if (mask & (WL_EVENT_HANGUP | WL_EVENT_ERROR)) {
|
|
|
|
|
wl_event_source_remove(server.ctl_source);
|
|
|
|
|
server.ctl_source = NULL;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ssize_t n = read(fd, buf + have, sizeof(buf) - have);
|
|
|
|
|
if (n <= 0) {
|
|
|
|
|
if (n == 0) {
|
|
|
|
|
wl_event_source_remove(server.ctl_source);
|
|
|
|
|
server.ctl_source = NULL;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
have += (size_t)n;
|
|
|
|
|
|
|
|
|
|
uint8_t msg[CTL_MAX_MSG];
|
|
|
|
|
while (have >= 5) {
|
|
|
|
|
size_t need = (buf[0] == CTL_CMD_MODS) ? 17 : 9;
|
|
|
|
|
if (have < need) break;
|
|
|
|
|
memcpy(msg, buf, need);
|
|
|
|
|
ctl_dispatch(msg, need);
|
|
|
|
|
memmove(buf, buf + need, have - need);
|
|
|
|
|
have -= need;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
/* ---------- host pointer ---------- */
|
|
|
|
|
|
|
|
|
|
static struct wlr_surface *surface_at(double x, double y,
|
|
|
|
|
double *sx, double *sy)
|
|
|
|
|
{
|
|
|
|
|
struct wlr_scene_node *node = wlr_scene_node_at(
|
|
|
|
|
&server.scene->tree.node, x, y, sx, sy);
|
|
|
|
|
if (node && node->type == WLR_SCENE_NODE_BUFFER) {
|
|
|
|
|
struct wlr_scene_buffer *buffer = wlr_scene_buffer_from_node(node);
|
|
|
|
|
struct wlr_scene_surface *scene_surface =
|
|
|
|
|
wlr_scene_surface_try_from_buffer(buffer);
|
|
|
|
|
if (scene_surface)
|
|
|
|
|
return scene_surface->surface;
|
|
|
|
|
}
|
|
|
|
|
return NULL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_motion(uint32_t time, double sx, double sy) {
|
|
|
|
|
if (!server.seat) return;
|
|
|
|
|
double nx = 0.0, ny = 0.0;
|
|
|
|
|
struct wlr_surface *surface = surface_at(sx, sy, &nx, &ny);
|
|
|
|
|
if (surface) {
|
|
|
|
|
wlr_seat_pointer_notify_enter(server.seat, surface, nx, ny);
|
|
|
|
|
wlr_seat_pointer_notify_motion(server.seat, time, nx, ny);
|
|
|
|
|
} else {
|
|
|
|
|
wlr_seat_pointer_notify_clear_focus(server.seat);
|
|
|
|
|
}
|
|
|
|
|
wlr_seat_pointer_notify_frame(server.seat);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_enter(void *data, struct wl_pointer *p, uint32_t serial,
|
|
|
|
|
struct wl_surface *surface, wl_fixed_t sx, wl_fixed_t sy)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)p; (void)serial; (void)surface;
|
|
|
|
|
host_ptr_motion(0, wl_fixed_to_double(sx), wl_fixed_to_double(sy));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_leave(void *data, struct wl_pointer *p, uint32_t serial,
|
|
|
|
|
struct wl_surface *surface)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)p; (void)serial; (void)surface;
|
|
|
|
|
if (server.seat) {
|
|
|
|
|
wlr_seat_pointer_notify_clear_focus(server.seat);
|
|
|
|
|
wlr_seat_pointer_notify_frame(server.seat);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_motion_event(void *data, struct wl_pointer *p, uint32_t time,
|
|
|
|
|
wl_fixed_t sx, wl_fixed_t sy)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)p;
|
|
|
|
|
host_ptr_motion(time, wl_fixed_to_double(sx), wl_fixed_to_double(sy));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_button(void *data, struct wl_pointer *p, uint32_t serial,
|
|
|
|
|
uint32_t time, uint32_t button, uint32_t state)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)p; (void)serial;
|
|
|
|
|
if (!server.seat) return;
|
|
|
|
|
wlr_seat_pointer_notify_button(server.seat, time, button, state);
|
|
|
|
|
wlr_seat_pointer_notify_frame(server.seat);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_axis(void *data, struct wl_pointer *p, uint32_t time,
|
|
|
|
|
uint32_t axis, wl_fixed_t value)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)p;
|
|
|
|
|
if (!server.seat) return;
|
2026-08-09 18:43:10 +10:00
|
|
|
int32_t discrete = axis < 2 ? host.axis_discrete[axis] : 0;
|
|
|
|
|
host.axis_discrete[axis] = 0;
|
2026-08-06 22:17:40 +10:00
|
|
|
wlr_seat_pointer_notify_axis(server.seat, time, axis,
|
2026-08-09 18:43:10 +10:00
|
|
|
wl_fixed_to_double(value), discrete,
|
2026-08-06 22:17:40 +10:00
|
|
|
host.axis_source,
|
|
|
|
|
WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL);
|
|
|
|
|
wlr_seat_pointer_notify_frame(server.seat);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_axis_source(void *data, struct wl_pointer *p, uint32_t source)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)p;
|
|
|
|
|
host.axis_source = source;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_axis_discrete(void *data, struct wl_pointer *p, uint32_t axis,
|
|
|
|
|
int32_t discrete)
|
|
|
|
|
{
|
2026-08-09 18:43:10 +10:00
|
|
|
(void)data; (void)p;
|
|
|
|
|
if (axis < 2)
|
|
|
|
|
host.axis_discrete[axis] += discrete;
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_axis_stop(void *data, struct wl_pointer *p, uint32_t time,
|
|
|
|
|
uint32_t axis)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)p; (void)time; (void)axis;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_ptr_frame(void *data, struct wl_pointer *p) {
|
|
|
|
|
(void)data; (void)p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wl_pointer_listener host_ptr_listener = {
|
|
|
|
|
.enter = host_ptr_enter,
|
|
|
|
|
.leave = host_ptr_leave,
|
|
|
|
|
.motion = host_ptr_motion_event,
|
|
|
|
|
.button = host_ptr_button,
|
|
|
|
|
.axis = host_ptr_axis,
|
|
|
|
|
.frame = host_ptr_frame,
|
|
|
|
|
.axis_source = host_ptr_axis_source,
|
|
|
|
|
.axis_stop = host_ptr_axis_stop,
|
|
|
|
|
.axis_discrete = host_ptr_axis_discrete,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ===================== Host init / EGL ===================== */
|
|
|
|
|
|
|
|
|
|
static bool host_init(void) {
|
|
|
|
|
const char *disp = getenv("WAYLAND_DISPLAY");
|
|
|
|
|
if (!disp)
|
|
|
|
|
disp = "wayland-0";
|
|
|
|
|
host.display = wl_display_connect(NULL);
|
|
|
|
|
if (!host.display) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: cannot connect to host compositor "
|
|
|
|
|
"(WAYLAND_DISPLAY=%s). Run me inside a running Wayland session.\n",
|
|
|
|
|
disp);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host.registry = wl_display_get_registry(host.display);
|
|
|
|
|
wl_registry_add_listener(host.registry, &host_registry_listener, NULL);
|
|
|
|
|
wl_display_roundtrip(host.display);
|
|
|
|
|
|
|
|
|
|
if (!host.compositor || !host.wm_base) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: host lacks compositor/xdg_wm_base\n");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host.surface = wl_compositor_create_surface(host.compositor);
|
|
|
|
|
host.xdg_surface = xdg_wm_base_get_xdg_surface(host.wm_base, host.surface);
|
|
|
|
|
host.toplevel = xdg_surface_get_toplevel(host.xdg_surface);
|
|
|
|
|
xdg_toplevel_set_title(host.toplevel, "tabbed-wlroots");
|
2026-08-07 10:32:11 +10:00
|
|
|
if (host.decoration) {
|
|
|
|
|
/* Ask the host compositor (labwc) for a server-side title bar, so
|
|
|
|
|
the nested window always has a host-drawn decoration above our
|
|
|
|
|
own tab content (including full-bleed clients like the test). */
|
|
|
|
|
host.decoration_obj = zxdg_decoration_manager_v1_get_toplevel_decoration(
|
|
|
|
|
host.decoration, host.toplevel);
|
|
|
|
|
zxdg_toplevel_decoration_v1_add_listener(host.decoration_obj,
|
|
|
|
|
&host_decoration_listener, NULL);
|
|
|
|
|
zxdg_toplevel_decoration_v1_set_mode(host.decoration_obj,
|
|
|
|
|
ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE);
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
xdg_surface_add_listener(host.xdg_surface, &host_xdg_surface_listener, NULL);
|
|
|
|
|
xdg_toplevel_add_listener(host.toplevel, &host_xdg_toplevel_listener, NULL);
|
|
|
|
|
xdg_wm_base_add_listener(host.wm_base, &host_xdg_wm_listener, NULL);
|
|
|
|
|
wl_surface_commit(host.surface);
|
|
|
|
|
wl_display_roundtrip(host.display);
|
|
|
|
|
|
|
|
|
|
if (host.seat) {
|
|
|
|
|
host.keyboard = wl_seat_get_keyboard(host.seat);
|
|
|
|
|
host.pointer = wl_seat_get_pointer(host.seat);
|
|
|
|
|
if (host.keyboard)
|
|
|
|
|
wl_keyboard_add_listener(host.keyboard, &host_kbd_listener, NULL);
|
|
|
|
|
if (host.pointer)
|
|
|
|
|
wl_pointer_add_listener(host.pointer, &host_ptr_listener, NULL);
|
|
|
|
|
}
|
2026-08-10 11:29:07 +10:00
|
|
|
bridge_init();
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
/* Try EGL on the host wayland platform */
|
|
|
|
|
pfn_eglGetPlatformDisplayEXT = (PFNEGLGETPLATFORMDISPLAYEXTPROC)
|
|
|
|
|
eglGetProcAddress("eglGetPlatformDisplayEXT");
|
|
|
|
|
if (!pfn_eglGetPlatformDisplayEXT) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: no EGL platform support; "
|
|
|
|
|
"falling back to software rendering\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host.egl_display = pfn_eglGetPlatformDisplayEXT(EGL_PLATFORM_WAYLAND_EXT,
|
|
|
|
|
host.display, NULL);
|
|
|
|
|
if (host.egl_display == EGL_NO_DISPLAY) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: EGL display failed; "
|
|
|
|
|
"falling back to software rendering\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (!eglInitialize(host.egl_display, NULL, NULL)) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: eglInitialize failed; "
|
|
|
|
|
"falling back to software rendering\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
eglBindAPI(EGL_OPENGL_ES_API);
|
|
|
|
|
|
|
|
|
|
const EGLint config_attribs[] = {
|
|
|
|
|
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
|
|
|
|
|
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
|
|
|
|
|
EGL_RED_SIZE, 8,
|
|
|
|
|
EGL_GREEN_SIZE, 8,
|
|
|
|
|
EGL_BLUE_SIZE, 8,
|
|
|
|
|
EGL_ALPHA_SIZE, 0,
|
|
|
|
|
EGL_NONE
|
|
|
|
|
};
|
|
|
|
|
EGLint ncfg = 0;
|
|
|
|
|
if (!eglChooseConfig(host.egl_display, config_attribs, &host.egl_config,
|
|
|
|
|
1, &ncfg) || ncfg < 1) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: no EGL config; software fallback\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const EGLint ctx_attribs[] = {
|
|
|
|
|
EGL_CONTEXT_CLIENT_VERSION, 2,
|
|
|
|
|
EGL_NONE
|
|
|
|
|
};
|
|
|
|
|
host.egl_context = eglCreateContext(host.egl_display, host.egl_config,
|
|
|
|
|
EGL_NO_CONTEXT, ctx_attribs);
|
|
|
|
|
if (host.egl_context == EGL_NO_CONTEXT) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: EGL context failed; software fallback\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host.width = 1280;
|
|
|
|
|
host.height = 800;
|
|
|
|
|
host.egl_window = wl_egl_window_create(host.surface, host.width, host.height);
|
|
|
|
|
host.egl_surface = eglCreateWindowSurface(host.egl_display, host.egl_config,
|
|
|
|
|
(EGLNativeWindowType)host.egl_window, NULL);
|
|
|
|
|
if (host.egl_surface == EGL_NO_SURFACE) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: EGL window surface failed; software fallback\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!eglMakeCurrent(host.egl_display, host.egl_surface, host.egl_surface,
|
|
|
|
|
host.egl_context)) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: eglMakeCurrent failed; software fallback\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
eglSwapInterval(host.egl_display, 0);
|
|
|
|
|
|
|
|
|
|
pfn_eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)egl_proc("eglCreateImageKHR");
|
|
|
|
|
pfn_eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)egl_proc("eglDestroyImageKHR");
|
|
|
|
|
pfn_glEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)
|
|
|
|
|
egl_proc("glEGLImageTargetTexture2DOES");
|
|
|
|
|
|
|
|
|
|
if (!gl_setup()) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: GL setup failed; software fallback\n");
|
|
|
|
|
host.egl_ok = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host.egl_ok = true;
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: EGL (GPU) rendering enabled\n");
|
2026-08-06 22:17:40 +10:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===================== Scene buffer -> host blit ===================== */
|
|
|
|
|
|
|
|
|
|
static bool import_scene_buffer(struct wlr_buffer *buf, GLuint tex) {
|
|
|
|
|
struct wlr_dmabuf_attributes attrs;
|
2026-08-07 07:48:27 +10:00
|
|
|
if (wlr_buffer_get_dmabuf(buf, &attrs)) {
|
|
|
|
|
if (attrs.n_planes < 1) return false;
|
|
|
|
|
|
|
|
|
|
EGLint att[48];
|
|
|
|
|
int n = 0;
|
|
|
|
|
att[n++] = EGL_WIDTH; att[n++] = attrs.width;
|
|
|
|
|
att[n++] = EGL_HEIGHT; att[n++] = attrs.height;
|
|
|
|
|
att[n++] = EGL_LINUX_DRM_FOURCC_EXT; att[n++] = (EGLint)attrs.format;
|
|
|
|
|
for (int p = 0; p < attrs.n_planes; p++) {
|
|
|
|
|
att[n++] = EGL_DMA_BUF_PLANE0_FD_EXT + p * 3; att[n++] = attrs.fd[p];
|
|
|
|
|
att[n++] = EGL_DMA_BUF_PLANE0_OFFSET_EXT + p * 3; att[n++] = (EGLint)attrs.offset[p];
|
|
|
|
|
att[n++] = EGL_DMA_BUF_PLANE0_PITCH_EXT + p * 3; att[n++] = (EGLint)attrs.stride[p];
|
|
|
|
|
}
|
|
|
|
|
if (attrs.modifier != DRM_FORMAT_MOD_INVALID) {
|
|
|
|
|
att[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT;
|
|
|
|
|
att[n++] = (EGLint)(attrs.modifier & 0xffffffff);
|
|
|
|
|
att[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT;
|
|
|
|
|
att[n++] = (EGLint)(attrs.modifier >> 32);
|
|
|
|
|
}
|
|
|
|
|
att[n++] = EGL_NONE;
|
|
|
|
|
|
|
|
|
|
EGLImageKHR img = pfn_eglCreateImageKHR(host.egl_display, EGL_NO_CONTEXT,
|
|
|
|
|
EGL_LINUX_DMA_BUF_EXT,
|
|
|
|
|
(EGLClientBuffer)NULL, att);
|
|
|
|
|
if (img == EGL_NO_IMAGE_KHR) {
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[import] eglCreateImageKHR failed "
|
|
|
|
|
"(format 0x%x modifier 0x%llx)\n", attrs.format,
|
2026-08-07 07:48:27 +10:00
|
|
|
(unsigned long long)attrs.modifier);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, tex);
|
|
|
|
|
pfn_glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, img);
|
|
|
|
|
pfn_eglDestroyImageKHR(host.egl_display, img);
|
|
|
|
|
return true;
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
2026-08-07 07:48:27 +10:00
|
|
|
|
|
|
|
|
/* Fallback for non-dmabuf buffers (on a headless backend the allocator
|
|
|
|
|
yields shm buffers). Upload via CPU read so shm clients and the shm
|
|
|
|
|
swapchain both render into the host window. */
|
|
|
|
|
void *data;
|
|
|
|
|
uint32_t fmt;
|
|
|
|
|
size_t stride;
|
|
|
|
|
if (!wlr_buffer_begin_data_ptr_access(buf, WLR_BUFFER_DATA_PTR_ACCESS_READ,
|
|
|
|
|
&data, &fmt, &stride)) {
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[import] buffer is neither dmabuf nor "
|
|
|
|
|
"CPU-readable\n");
|
2026-08-06 22:17:40 +10:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, tex);
|
2026-08-07 07:48:27 +10:00
|
|
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
|
|
|
|
switch (fmt) {
|
|
|
|
|
case DRM_FORMAT_XRGB8888:
|
|
|
|
|
case DRM_FORMAT_ARGB8888:
|
|
|
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, buf->width, buf->height, 0,
|
|
|
|
|
GL_BGRA_EXT, GL_UNSIGNED_BYTE, data);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[import] unsupported CPU fallback format "
|
|
|
|
|
"0x%x\n", fmt);
|
2026-08-07 07:48:27 +10:00
|
|
|
wlr_buffer_end_data_ptr_access(buf);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
wlr_buffer_end_data_ptr_access(buf);
|
2026-08-06 22:17:40 +10:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 19:07:40 +10:00
|
|
|
/* Draw one popup surface buffer into the presented window at its on-screen
|
|
|
|
|
position. This bypasses wlroots' scene rendering for popups, whose
|
|
|
|
|
premultiplied-alpha blend paints nothing in our nested EGL pipeline.
|
|
|
|
|
|
|
|
|
|
Each popup surface is composited exactly once per frame. A sub-menu is a
|
|
|
|
|
popup nested under its parent menu's scene tree, so it is reachable both
|
|
|
|
|
from its own tree and from the parent menu's tree; the surfaces-drawn array
|
|
|
|
|
prevents it being drawn twice. Position uses wlr_scene_node_coords (the
|
|
|
|
|
true absolute offset, including every ancestor), because the iterator's
|
|
|
|
|
sx/sy only sums offsets from its own tree root and would place a nested
|
|
|
|
|
popup too high/left by the missing ancestor offsets. */
|
|
|
|
|
struct popup_draw_ctx {
|
|
|
|
|
int win_w, win_h;
|
|
|
|
|
struct wlr_surface **drawn;
|
|
|
|
|
int ndrawn;
|
|
|
|
|
};
|
2026-08-06 22:20:34 +10:00
|
|
|
|
2026-08-07 19:07:40 +10:00
|
|
|
static void popup_draw_cb(struct wlr_scene_buffer *buffer,
|
|
|
|
|
int sx, int sy, void *user_data) {
|
|
|
|
|
struct popup_draw_ctx *ctx = user_data;
|
|
|
|
|
if (!buffer->buffer) return;
|
|
|
|
|
struct wlr_scene_surface *ss = wlr_scene_surface_try_from_buffer(buffer);
|
|
|
|
|
if (!ss) return;
|
|
|
|
|
for (int i = 0; i < ctx->ndrawn; i++) {
|
|
|
|
|
if (ctx->drawn[i] == ss->surface) return;
|
|
|
|
|
}
|
|
|
|
|
ctx->drawn[ctx->ndrawn++] = ss->surface;
|
|
|
|
|
(void)sx; (void)sy;
|
|
|
|
|
int lx = 0, ly = 0;
|
|
|
|
|
wlr_scene_node_coords(&buffer->node, &lx, &ly);
|
|
|
|
|
if (!import_scene_buffer(buffer->buffer, host.popup_tex)) return;
|
|
|
|
|
glEnable(GL_BLEND);
|
|
|
|
|
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
|
|
|
|
|
draw_texture_rect(host.popup_tex, buffer->buffer->width,
|
|
|
|
|
buffer->buffer->height,
|
|
|
|
|
0, 0, buffer->buffer->width, buffer->buffer->height,
|
|
|
|
|
lx, ly, ctx->win_w, ctx->win_h);
|
|
|
|
|
glDisable(GL_BLEND);
|
2026-08-06 22:20:34 +10:00
|
|
|
}
|
|
|
|
|
|
2026-08-07 19:07:40 +10:00
|
|
|
static void composite_popups(void) {
|
|
|
|
|
struct wlr_surface *drawn[64];
|
|
|
|
|
struct popup_draw_ctx ctx = {
|
|
|
|
|
.win_w = host.width,
|
|
|
|
|
.win_h = host.height,
|
|
|
|
|
.drawn = drawn,
|
|
|
|
|
.ndrawn = 0,
|
|
|
|
|
};
|
|
|
|
|
struct tabbed_popup *tp;
|
|
|
|
|
wl_list_for_each(tp, &popups, link)
|
|
|
|
|
wlr_scene_node_for_each_buffer(&tp->scene->node,
|
|
|
|
|
popup_draw_cb, &ctx);
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
2026-08-08 12:14:29 +10:00
|
|
|
/* Pace presents to the host compositor: this fires after the host has shown
|
|
|
|
|
the buffer we queued, so we know we may queue the next one. Any frame drawn
|
|
|
|
|
while a present is still pending is coalesced into the back buffer and
|
|
|
|
|
presented here, keeping exactly one outstanding buffer (no burst, no
|
|
|
|
|
tearing on busy repainters like terminals). */
|
|
|
|
|
static const struct wl_callback_listener host_frame_listener;
|
|
|
|
|
/* Request a host frame callback and present the drawing in the back buffer.
|
|
|
|
|
The callback must be requested *before* the swap: frame requests are part
|
|
|
|
|
of the state committed by eglSwapBuffers, so requesting it after would wait
|
|
|
|
|
on a commit that never comes. */
|
|
|
|
|
static void present_to_host(void) {
|
|
|
|
|
host.frame_cb = wl_surface_frame(host.surface);
|
|
|
|
|
wl_callback_add_listener(host.frame_cb, &host_frame_listener, NULL);
|
|
|
|
|
eglSwapBuffers(host.egl_display, host.egl_surface);
|
|
|
|
|
host.present_pending = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void host_frame_done(void *data, struct wl_callback *callback,
|
|
|
|
|
uint32_t callback_data)
|
|
|
|
|
{
|
|
|
|
|
(void)data; (void)callback; (void)callback_data;
|
|
|
|
|
if (host.frame_cb) {
|
|
|
|
|
wl_callback_destroy(host.frame_cb);
|
|
|
|
|
host.frame_cb = NULL;
|
|
|
|
|
}
|
|
|
|
|
host.present_pending = false;
|
|
|
|
|
|
|
|
|
|
if (host.back_dirty) {
|
|
|
|
|
host.back_dirty = false;
|
|
|
|
|
present_to_host();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static const struct wl_callback_listener host_frame_listener = {
|
|
|
|
|
.done = host_frame_done,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* The scene is rendered by the wlroots GLES2 renderer, which owns its own
|
|
|
|
|
EGL display/context, while we sample that buffer from a *different* EGL
|
|
|
|
|
context (host.egl_display). Without an explicit barrier the GPU may still
|
|
|
|
|
be executing the renderer's draw commands when we import+read the dmabuf,
|
|
|
|
|
which shows as flicker/tearing on content that repaints continuously.
|
|
|
|
|
Make the renderer's context current on this thread and glFinish() so the
|
|
|
|
|
buffer is guaranteed fully rendered before we blit it. */
|
|
|
|
|
static void sync_renderer_context(void) {
|
|
|
|
|
struct wlr_egl *src = wlr_gles2_renderer_get_egl(server.renderer);
|
|
|
|
|
if (!src) return;
|
|
|
|
|
EGLDisplay d = wlr_egl_get_display(src);
|
|
|
|
|
EGLContext c = wlr_egl_get_context(src);
|
|
|
|
|
if (d == EGL_NO_DISPLAY || c == EGL_NO_CONTEXT) return;
|
|
|
|
|
if (d == host.egl_display && c == host.egl_context) return;
|
|
|
|
|
eglMakeCurrent(d, EGL_NO_SURFACE, EGL_NO_SURFACE, c);
|
|
|
|
|
glFinish();
|
|
|
|
|
eglMakeCurrent(host.egl_display, host.egl_surface, host.egl_surface,
|
|
|
|
|
host.egl_context);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
static void blit_scene_buffer(struct wlr_buffer *buf) {
|
|
|
|
|
int w = host.width, h = host.height;
|
|
|
|
|
glViewport(0, 0, w, h);
|
2026-08-08 12:14:29 +10:00
|
|
|
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
2026-08-06 22:17:40 +10:00
|
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
|
|
|
|
2026-08-08 12:14:29 +10:00
|
|
|
bool imported = buf && import_scene_buffer(buf, host.import_tex);
|
|
|
|
|
if (imported) {
|
2026-08-06 22:17:40 +10:00
|
|
|
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);
|
|
|
|
|
glDisable(GL_BLEND);
|
|
|
|
|
}
|
2026-08-07 19:07:40 +10:00
|
|
|
composite_popups();
|
2026-08-08 12:14:29 +10:00
|
|
|
|
|
|
|
|
if (dump_host_req) {
|
|
|
|
|
dump_host_req = false;
|
|
|
|
|
dump_host_back();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!imported) {
|
|
|
|
|
/* Never present a frame that is only the clear colour: keep the last
|
|
|
|
|
good frame on the host. A later frame with a good import presents
|
|
|
|
|
normally. */
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (host.present_pending) {
|
|
|
|
|
/* Host hasn't shown the previous buffer yet: redraw into the back
|
|
|
|
|
buffer and coalesce the swap until the frame callback arrives. */
|
|
|
|
|
host.back_dirty = true;
|
|
|
|
|
} else {
|
|
|
|
|
present_to_host();
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void render_and_present(void) {
|
|
|
|
|
if (!host.egl_ok || !host.configured) return;
|
|
|
|
|
if (!server.scene_output || !server.swapchain) return;
|
|
|
|
|
|
|
|
|
|
struct wlr_scene_output_state_options options = {
|
|
|
|
|
.swapchain = server.swapchain,
|
|
|
|
|
};
|
|
|
|
|
struct wlr_output_state state;
|
|
|
|
|
wlr_output_state_init(&state);
|
|
|
|
|
bool rendered = wlr_scene_output_build_state(server.scene_output, &state,
|
|
|
|
|
&options);
|
|
|
|
|
if (!rendered || !state.buffer) {
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[render] build_state failed\n");
|
2026-08-06 22:17:40 +10:00
|
|
|
wlr_output_state_finish(&state);
|
|
|
|
|
goto frame_done;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 01:22:01 +10:00
|
|
|
/* [diag] What did wlroots composite? A swapchain-sized buffer means we
|
|
|
|
|
composited the scene; anything else implies direct scanout of a client
|
|
|
|
|
buffer (which our blit may or may not import). */
|
2026-08-09 02:02:22 +10:00
|
|
|
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
2026-08-09 01:22:01 +10:00
|
|
|
struct wlr_output *out = server.output;
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[diag] frame buffer %dx%d (output %dx%d)\n",
|
2026-08-09 01:22:01 +10:00
|
|
|
state.buffer->width, state.buffer->height,
|
|
|
|
|
out ? out->width : 0, out ? out->height : 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 12:14:29 +10:00
|
|
|
/* Barrier between the wlr renderer's context and our host blit. */
|
|
|
|
|
sync_renderer_context();
|
|
|
|
|
|
|
|
|
|
if (dump_scene_req) {
|
|
|
|
|
dump_scene_req = false;
|
|
|
|
|
dump_scene_buffer(state.buffer);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 01:22:01 +10:00
|
|
|
static int autodump = -1;
|
|
|
|
|
if (autodump < 0)
|
|
|
|
|
autodump = getenv("TABBED_AUTODUMP") ? 1 : 0;
|
|
|
|
|
if (autodump > 0 && client_dumped && --autodump == 0) {
|
|
|
|
|
dump_scene_req = true;
|
|
|
|
|
dump_host_req = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
blit_scene_buffer(state.buffer);
|
|
|
|
|
|
|
|
|
|
if (!wlr_output_commit_state(server.output, &state))
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_DEBUG, "[render] output commit failed\n");
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
frame_done:
|
|
|
|
|
struct timespec now;
|
|
|
|
|
clock_gettime(CLOCK_MONOTONIC, &now);
|
|
|
|
|
wlr_scene_output_send_frame_done(server.scene_output, &now);
|
|
|
|
|
|
|
|
|
|
wlr_output_state_finish(&state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===================== Launch client ===================== */
|
|
|
|
|
|
|
|
|
|
static void launch_client(int idx) {
|
|
|
|
|
pid_t pid = fork();
|
|
|
|
|
if (pid == 0) {
|
2026-08-07 21:23:01 +10:00
|
|
|
/* Own process group so we can kill the whole client tree on exit. */
|
|
|
|
|
setpgid(0, 0);
|
2026-08-06 22:17:40 +10:00
|
|
|
const char *xdg_runtime = getenv("XDG_RUNTIME_DIR");
|
|
|
|
|
if (!xdg_runtime) xdg_runtime = "/tmp";
|
|
|
|
|
setenv("WAYLAND_DISPLAY", tabbed_socket, 1);
|
|
|
|
|
setenv("XDG_RUNTIME_DIR", xdg_runtime, 1);
|
|
|
|
|
setenv("GDK_BACKEND", "wayland", 1);
|
|
|
|
|
setenv("QT_QPA_PLATFORM", "wayland", 1);
|
|
|
|
|
unsetenv("DISPLAY");
|
2026-08-09 02:02:22 +10:00
|
|
|
/* Unless debugging, don't let client chatter spill onto the launching
|
|
|
|
|
terminal; the client inherits our stdout/stderr. */
|
|
|
|
|
if (tabbed_log_level() < TABBED_LOG_DEBUG) {
|
|
|
|
|
int nullfd = open("/dev/null", O_WRONLY);
|
|
|
|
|
if (nullfd >= 0) {
|
|
|
|
|
dup2(nullfd, STDOUT_FILENO);
|
|
|
|
|
dup2(nullfd, STDERR_FILENO);
|
|
|
|
|
if (nullfd > STDERR_FILENO) close(nullfd);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
execlp("/bin/sh", "sh", "-c", server.apps[idx]->cmd, NULL);
|
|
|
|
|
_exit(127);
|
|
|
|
|
}
|
|
|
|
|
if (pid > 0)
|
|
|
|
|
server.apps[idx]->pid = pid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===================== Crash handler ===================== */
|
|
|
|
|
|
|
|
|
|
static void crash_handler(int sig, siginfo_t *info, void *ctx) {
|
|
|
|
|
(void)sig; (void)info; (void)ctx;
|
|
|
|
|
void *bt[64];
|
|
|
|
|
int n = backtrace(bt, 64);
|
|
|
|
|
write(2, "\n=== BACKTRACE ===\n", 19);
|
|
|
|
|
backtrace_symbols_fd(bt, n, 2);
|
2026-08-09 12:02:21 +10:00
|
|
|
remove_socket_file();
|
2026-08-06 22:17:40 +10:00
|
|
|
_exit(139);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void sigchld_handler(int sig) {
|
|
|
|
|
(void)sig;
|
|
|
|
|
int status;
|
|
|
|
|
pid_t pid;
|
2026-08-09 02:02:22 +10:00
|
|
|
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
|
|
|
|
|
while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
|
|
|
|
|
fprintf(stderr, "[child] pid %d exited status 0x%x\n", pid, status);
|
|
|
|
|
} else {
|
|
|
|
|
while (waitpid(-1, &status, WNOHANG) > 0)
|
|
|
|
|
;
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 11:50:40 +10:00
|
|
|
/* On SIGTERM or Ctrl-C (SIGINT), take down the clients and exit cleanly
|
|
|
|
|
rather than leaving them orphaned when the compositor dies. The clients
|
|
|
|
|
live in their own process groups, so a terminal Ctrl-C only reaches us. */
|
|
|
|
|
static void close_signal_handler(int sig) {
|
2026-08-07 21:23:01 +10:00
|
|
|
(void)sig;
|
|
|
|
|
shutdown_cleanup();
|
|
|
|
|
_exit(0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 01:22:01 +10:00
|
|
|
/* SIGUSR1: save the current scene buffer and host back buffer on the next
|
|
|
|
|
frame, for inspecting what the client has actually drawn so far. */
|
|
|
|
|
static void sigusr1_handler(int sig) {
|
|
|
|
|
(void)sig;
|
|
|
|
|
dump_scene_req = 1;
|
|
|
|
|
dump_host_req = 1;
|
|
|
|
|
needs_redraw = 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
/* ===================== Main ===================== */
|
|
|
|
|
|
|
|
|
|
int main(int argc, char *argv[]) {
|
|
|
|
|
snprintf(tabbed_socket, sizeof(tabbed_socket), "tabbed-wayland-%d",
|
|
|
|
|
(int)getpid());
|
2026-08-09 12:02:21 +10:00
|
|
|
const char *runtime = getenv("XDG_RUNTIME_DIR");
|
|
|
|
|
if (!runtime) runtime = "/tmp";
|
|
|
|
|
snprintf(socket_path, sizeof(socket_path), "%s/%s", runtime, tabbed_socket);
|
|
|
|
|
/* Clear a stale leftover (e.g. from a crashed previous run with the same
|
|
|
|
|
name) before we try to bind; harmless if there is none. */
|
|
|
|
|
remove_socket_file();
|
2026-08-06 22:17:40 +10:00
|
|
|
|
2026-08-09 02:02:22 +10:00
|
|
|
/* Resolve TABBED_LOG up front so signal handlers can read the cached
|
|
|
|
|
level without touching non-async-signal-safe functions. */
|
|
|
|
|
tabbed_log_level_init();
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
struct sigaction crash_sa = {
|
|
|
|
|
.sa_sigaction = crash_handler,
|
|
|
|
|
.sa_flags = SA_SIGINFO | SA_NODEFER,
|
|
|
|
|
};
|
|
|
|
|
sigaction(SIGSEGV, &crash_sa, NULL);
|
|
|
|
|
sigaction(SIGABRT, &crash_sa, NULL);
|
|
|
|
|
signal(SIGCHLD, sigchld_handler);
|
2026-08-09 11:50:40 +10:00
|
|
|
signal(SIGTERM, close_signal_handler);
|
|
|
|
|
signal(SIGINT, close_signal_handler);
|
2026-08-09 01:22:01 +10:00
|
|
|
signal(SIGUSR1, sigusr1_handler);
|
2026-08-09 02:02:22 +10:00
|
|
|
|
|
|
|
|
/* wlroots logs follow TABBED_LOG: quiet keeps only errors, info drops
|
|
|
|
|
protocol noise, debug shows everything. */
|
|
|
|
|
switch (tabbed_log_level()) {
|
|
|
|
|
case TABBED_LOG_DEBUG:
|
|
|
|
|
wlr_log_init(WLR_DEBUG, NULL);
|
|
|
|
|
break;
|
|
|
|
|
case TABBED_LOG_INFO:
|
|
|
|
|
wlr_log_init(WLR_INFO, NULL);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
wlr_log_init(WLR_ERROR, NULL);
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
server.wl_display = wl_display_create();
|
|
|
|
|
if (!server.wl_display) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: failed to create Wayland display\n");
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
server.loop = wl_display_get_event_loop(server.wl_display);
|
|
|
|
|
|
2026-08-10 07:29:10 +10:00
|
|
|
/* stdin event feed: lets tests/drivers inject raw keyboard events
|
|
|
|
|
into the real host key handlers without a host compositor. */
|
|
|
|
|
server.ctl_source = wl_event_loop_add_fd(server.loop, STDIN_FILENO,
|
|
|
|
|
WL_EVENT_READABLE,
|
|
|
|
|
ctl_cb, NULL);
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
server.backend = wlr_headless_backend_create(server.loop);
|
|
|
|
|
if (!server.backend) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: failed to create headless backend\n");
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
server.renderer = wlr_renderer_autocreate(server.backend);
|
|
|
|
|
if (!server.renderer) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: no GPU renderer available\n");
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
wlr_renderer_init_wl_display(server.renderer, server.wl_display);
|
|
|
|
|
|
|
|
|
|
server.allocator = wlr_allocator_autocreate(server.backend, server.renderer);
|
|
|
|
|
if (!server.allocator) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: failed to create allocator\n");
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 13:09:43 +10:00
|
|
|
/* wl_surface at v4: legacy clients (e.g. VLC's Wayland vout) place video
|
|
|
|
|
via non-zero wl_surface.attach offsets, which is a protocol violation
|
|
|
|
|
from v5 on. Advertise v4 so those offsets are accepted and ignored,
|
|
|
|
|
like before the v5 breaking change. */
|
|
|
|
|
wlr_compositor_create(server.wl_display, 4, server.renderer);
|
2026-08-06 22:17:40 +10:00
|
|
|
wlr_subcompositor_create(server.wl_display);
|
|
|
|
|
wlr_shm_create_with_renderer(server.wl_display, 1, server.renderer);
|
|
|
|
|
wlr_linux_dmabuf_v1_create_with_renderer(server.wl_display, 5, server.renderer);
|
|
|
|
|
wlr_data_device_manager_create(server.wl_display);
|
2026-08-10 11:29:07 +10:00
|
|
|
wlr_data_control_manager_v1_create(server.wl_display);
|
2026-08-06 22:17:40 +10:00
|
|
|
|
2026-08-09 01:22:01 +10:00
|
|
|
/* Never let wlroots present a client buffer to the headless output
|
|
|
|
|
directly (direct scanout). We must composite into our own swapchain so
|
|
|
|
|
the nested frame is exactly what the host window shows; scanout of a
|
|
|
|
|
client buffer hands us a buffer our host blit can't keep in sync and the
|
|
|
|
|
window goes black (seen with Firefox). The scene field is WLR_PRIVATE,
|
|
|
|
|
so the supported way is this env var, parsed at wlr_scene_create(). */
|
|
|
|
|
setenv("WLR_SCENE_DISABLE_DIRECT_SCANOUT", "1", 1);
|
|
|
|
|
|
2026-08-06 22:17:40 +10:00
|
|
|
server.scene = wlr_scene_create();
|
|
|
|
|
server.xdg_shell = wlr_xdg_shell_create(server.wl_display, 6);
|
|
|
|
|
|
|
|
|
|
server.seat = wlr_seat_create(server.wl_display, "tabbed");
|
|
|
|
|
wlr_seat_set_capabilities(server.seat,
|
|
|
|
|
WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_KEYBOARD);
|
2026-08-10 11:29:07 +10:00
|
|
|
bridge_attach_seat();
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
/* virtual keyboard fed from the host keyboard */
|
|
|
|
|
static const struct wlr_keyboard_impl keyboard_impl = {
|
|
|
|
|
.name = "tabbed-keyboard",
|
|
|
|
|
};
|
|
|
|
|
wlr_keyboard_init(&server.keyboard, &keyboard_impl, "tabbed-keyboard");
|
|
|
|
|
|
|
|
|
|
/* fallback keymap until the host sends one */
|
|
|
|
|
server.xkb_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
|
|
|
|
|
if (server.xkb_ctx) {
|
2026-08-09 02:28:36 +10:00
|
|
|
xkb_context_set_log_level(server.xkb_ctx, XKB_LOG_LEVEL_DEBUG);
|
|
|
|
|
xkb_context_set_log_fn(server.xkb_ctx, tabbed_xkb_log);
|
2026-08-06 22:17:40 +10:00
|
|
|
struct xkb_rule_names names = {
|
|
|
|
|
.rules = NULL, .model = "pc105",
|
|
|
|
|
.layout = "us", .variant = NULL, .options = NULL,
|
|
|
|
|
};
|
|
|
|
|
server.xkb_keymap = xkb_keymap_new_from_names(
|
|
|
|
|
server.xkb_ctx, &names, XKB_KEYMAP_COMPILE_NO_FLAGS);
|
|
|
|
|
if (server.xkb_keymap)
|
|
|
|
|
wlr_keyboard_set_keymap(&server.keyboard, server.xkb_keymap);
|
|
|
|
|
}
|
|
|
|
|
wlr_seat_set_keyboard(server.seat, &server.keyboard);
|
|
|
|
|
|
|
|
|
|
server.new_output.notify = handle_new_output;
|
|
|
|
|
wl_signal_add(&server.backend->events.new_output, &server.new_output);
|
|
|
|
|
server.new_toplevel.notify = handle_new_toplevel;
|
|
|
|
|
wl_signal_add(&server.xdg_shell->events.new_toplevel, &server.new_toplevel);
|
2026-08-07 19:07:40 +10:00
|
|
|
server.new_popup.notify = handle_new_popup;
|
|
|
|
|
wl_signal_add(&server.xdg_shell->events.new_popup, &server.new_popup);
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
/* headless output; new_output fires during backend start */
|
|
|
|
|
host.width = 1280;
|
|
|
|
|
host.height = 800;
|
|
|
|
|
wlr_headless_add_output(server.backend, host.width, host.height);
|
|
|
|
|
|
|
|
|
|
if (!wlr_backend_start(server.backend)) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: failed to start backend\n");
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (wl_display_add_socket(server.wl_display, tabbed_socket) < 0) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: failed to add socket '%s': %s\n",
|
|
|
|
|
tabbed_socket, strerror(errno));
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Now connect to the host compositor and present inside it. */
|
|
|
|
|
if (!host_init()) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: could not start nested session\n");
|
2026-08-09 12:02:21 +10:00
|
|
|
remove_socket_file();
|
2026-08-06 22:17:40 +10:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "Wayland socket: %s\n", tabbed_socket);
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
int total_apps = argc - 1;
|
|
|
|
|
if (total_apps == 0) {
|
2026-08-09 01:22:01 +10:00
|
|
|
fprintf(stderr, "usage: %s <prog> [prog...]\n"
|
|
|
|
|
" Each <prog> is one shell command; quote it when it "
|
|
|
|
|
"takes args, e.g. \"firefox -no-remote\".\n"
|
|
|
|
|
" Arguments written bare (starting with '-') launch a "
|
|
|
|
|
"bogus extra tab.\n", argv[0]);
|
2026-08-09 12:02:21 +10:00
|
|
|
remove_socket_file();
|
2026-08-06 22:17:40 +10:00
|
|
|
return 1;
|
|
|
|
|
}
|
2026-08-09 12:15:13 +10:00
|
|
|
server.apps = calloc((size_t)total_apps, sizeof(*server.apps));
|
|
|
|
|
if (!server.apps) {
|
|
|
|
|
fprintf(stderr, "tabbed-wlroots: out of memory for %d apps\n",
|
|
|
|
|
total_apps);
|
|
|
|
|
remove_socket_file();
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
server.apps_cap = total_apps;
|
2026-08-06 22:17:40 +10:00
|
|
|
|
|
|
|
|
for (int i = 0; i < total_apps; i++) {
|
|
|
|
|
struct tabbed_app *app = calloc(1, sizeof(*app));
|
2026-08-09 12:15:13 +10:00
|
|
|
if (!app) {
|
|
|
|
|
remove_socket_file();
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
app->cmd = strdup(argv[i + 1]);
|
|
|
|
|
app->pid = -1;
|
|
|
|
|
server.apps[i] = app;
|
|
|
|
|
}
|
|
|
|
|
server.num_apps = total_apps;
|
|
|
|
|
|
2026-08-09 01:22:01 +10:00
|
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
|
|
|
if (server.apps[i]->cmd[0] == '-') {
|
|
|
|
|
if (i > 0) {
|
|
|
|
|
fprintf(stderr,
|
|
|
|
|
"tabbed-wlroots: warning: %s starts with '-'; it is "
|
|
|
|
|
"run as its own tab, not an option to %s\n"
|
|
|
|
|
" If intended, quote it: \"%s %s\"\n",
|
|
|
|
|
server.apps[i]->cmd, server.apps[i - 1]->cmd,
|
|
|
|
|
server.apps[i - 1]->cmd, server.apps[i]->cmd);
|
|
|
|
|
} else {
|
|
|
|
|
fprintf(stderr,
|
|
|
|
|
"tabbed-wlroots: warning: %s starts with '-'; if this "
|
|
|
|
|
"is meant for a first command, quote them together, "
|
|
|
|
|
"e.g. \"%s\"\n",
|
|
|
|
|
server.apps[i]->cmd, server.apps[i]->cmd);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
launch_client(i);
|
2026-08-09 01:22:01 +10:00
|
|
|
}
|
2026-08-06 22:17:40 +10:00
|
|
|
|
2026-08-09 02:02:22 +10:00
|
|
|
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. "
|
2026-08-06 22:17:40 +10:00
|
|
|
"Ctrl+PageUp/PageDown switches tabs.\n");
|
|
|
|
|
|
|
|
|
|
/* Main event loop: service both the server and the host displays */
|
|
|
|
|
int server_fd = wl_event_loop_get_fd(server.loop);
|
|
|
|
|
int host_fd = wl_display_get_fd(host.display);
|
|
|
|
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
wl_display_dispatch_pending(host.display);
|
|
|
|
|
|
|
|
|
|
/* Never block in a host read: use the non-blocking prepare/read
|
|
|
|
|
pattern so the server loop (headless frame timer) is always
|
|
|
|
|
serviced while waiting on the host socket. */
|
|
|
|
|
if (wl_display_prepare_read(host.display) == 0) {
|
|
|
|
|
wl_display_flush(host.display);
|
|
|
|
|
struct pollfd fds[2] = {
|
|
|
|
|
{ .fd = server_fd, .events = POLLIN },
|
|
|
|
|
{ .fd = host_fd, .events = POLLIN },
|
|
|
|
|
};
|
|
|
|
|
int ret;
|
|
|
|
|
do {
|
|
|
|
|
ret = poll(fds, 2, -1);
|
|
|
|
|
} while (ret < 0 && errno == EINTR);
|
|
|
|
|
|
|
|
|
|
if (ret > 0 && (fds[1].revents & (POLLIN | POLLERR)))
|
|
|
|
|
wl_display_read_events(host.display);
|
|
|
|
|
else
|
|
|
|
|
wl_display_cancel_read(host.display);
|
|
|
|
|
|
|
|
|
|
if (ret > 0 && (fds[0].revents & (POLLIN | POLLERR)))
|
|
|
|
|
wl_event_loop_dispatch(server.loop, 0);
|
|
|
|
|
} else {
|
|
|
|
|
wl_event_loop_dispatch(server.loop, 0);
|
|
|
|
|
}
|
|
|
|
|
wl_display_flush_clients(server.wl_display);
|
|
|
|
|
wl_display_dispatch_pending(host.display);
|
|
|
|
|
|
|
|
|
|
/* The headless output fires a frame signal at ~60Hz; render then
|
|
|
|
|
(also when a redraw was requested directly). */
|
|
|
|
|
if (frame_pending || needs_redraw) {
|
|
|
|
|
frame_pending = false;
|
|
|
|
|
needs_redraw = false;
|
|
|
|
|
render_and_present();
|
|
|
|
|
}
|
|
|
|
|
wl_display_flush(host.display);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 21:23:01 +10:00
|
|
|
/* Unreachable in practice: every shutdown path exits via
|
|
|
|
|
shutdown_cleanup(). Kept for completeness. */
|
|
|
|
|
shutdown_cleanup();
|
2026-08-06 22:17:40 +10:00
|
|
|
return 0;
|
|
|
|
|
}
|