each window title now sits in its own grey box matching the blue highlight size, lightened so it separates from the panel, and rows are spaced so the boxes no longer touch.
8502 lines
338 KiB
C
8502 lines
338 KiB
C
/* kick.c -- nested kick 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,
|
|
* enter/leave, input regions and coordinate translation. Clients are always
|
|
* maximized to the host content area; a client's fullscreen request is
|
|
* honored and mirrors onto the host window, and vice versa.
|
|
*/
|
|
|
|
#define _GNU_SOURCE
|
|
|
|
#include <errno.h>
|
|
#include <execinfo.h>
|
|
#include <fcntl.h>
|
|
#include <linux/memfd.h>
|
|
#include <poll.h>
|
|
#include <signal.h>
|
|
#include <stdarg.h>
|
|
#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>
|
|
|
|
#include <libdecor.h>
|
|
|
|
#include "xdg-shell-client.h"
|
|
#include "wlr-data-control-client.h"
|
|
|
|
#include <EGL/egl.h>
|
|
#include <EGL/eglext.h>
|
|
#include <GLES2/gl2.h>
|
|
#include <GLES2/gl2ext.h>
|
|
|
|
#include <xkbcommon/xkbcommon.h>
|
|
|
|
/* xkbcommon 1.6 renamed/removed the Mod4 macro: >= 1.7 defines only the
|
|
legacy XKB_MOD_NAME_LOGO (kept for compat), < 1.7 defines the modern
|
|
XKB_MOD_NAME_MOD4. Prefer the modern name where it exists. */
|
|
#ifdef XKB_MOD_NAME_MOD4
|
|
#define XKB_PREFIX_MOD4_NAME XKB_MOD_NAME_MOD4
|
|
#else
|
|
#define XKB_PREFIX_MOD4_NAME XKB_MOD_NAME_LOGO
|
|
#endif
|
|
|
|
#include <ft2build.h>
|
|
#include FT_FREETYPE_H
|
|
|
|
#include <cairo.h>
|
|
#include <librsvg/rsvg.h>
|
|
#include <png.h>
|
|
|
|
#include <wlr/backend.h>
|
|
#include <wlr/backend/headless.h>
|
|
#include <wlr/backend/session.h>
|
|
#include <wlr/interfaces/wlr_keyboard.h>
|
|
#include <wlr/render/allocator.h>
|
|
#include <wlr/render/drm_format_set.h>
|
|
#include <wlr/render/egl.h>
|
|
#include <wlr/render/gles2.h>
|
|
#include <wlr/render/pass.h>
|
|
#include <wlr/render/swapchain.h>
|
|
#include <wlr/render/wlr_renderer.h>
|
|
#include <wlr/render/wlr_texture.h>
|
|
#include <wlr/types/wlr_buffer.h>
|
|
#include <wlr/types/wlr_compositor.h>
|
|
#include <wlr/types/wlr_cursor.h>
|
|
#include <wlr/types/wlr_cursor_shape_v1.h>
|
|
#include <wlr/types/wlr_data_control_v1.h>
|
|
#include <wlr/types/wlr_data_device.h>
|
|
#include <wlr/types/wlr_input_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_output_layout.h>
|
|
#include <wlr/types/wlr_pointer.h>
|
|
#include <wlr/types/wlr_pointer_constraints_v1.h>
|
|
#include <wlr/types/wlr_presentation_time.h>
|
|
#include <wlr/types/wlr_primary_selection.h>
|
|
#include <wlr/types/wlr_primary_selection_v1.h>
|
|
#include <wlr/types/wlr_relative_pointer_v1.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_viewporter.h>
|
|
#include <wlr/types/wlr_xcursor_manager.h>
|
|
#include <wlr/types/wlr_xdg_output_v1.h>
|
|
#include <wlr/types/wlr_xdg_shell.h>
|
|
#include <wlr/util/log.h>
|
|
|
|
/* XWayland support is on when the linked wlroots was built with it
|
|
(WLR_HAS_XWAYLAND in the public <wlr/config.h>) and the xcb dev headers
|
|
are available (KICK_HAVE_XCB from meson.build). Anything missing and the
|
|
X11 branches below compile away. */
|
|
#include <wlr/config.h>
|
|
#if defined(WLR_HAS_XWAYLAND) && defined(KICK_HAVE_XCB)
|
|
#define HAVE_XWAYLAND 1
|
|
#else
|
|
#define HAVE_XWAYLAND 0
|
|
#endif
|
|
|
|
#if HAVE_XWAYLAND
|
|
#include <wlr/xwayland/xwayland.h>
|
|
#endif
|
|
|
|
/* server.apps grows as needed; there is no tab limit. */
|
|
|
|
/* ===================== Server-side (wlroots) state ===================== */
|
|
|
|
struct kick_app {
|
|
char *cmd;
|
|
pid_t pid;
|
|
/* Process group the *connected* client lives in. Command-line commands
|
|
are forked all at once at startup (see launch_all_cli), so a CLI tab's
|
|
slot is claimed by whichever command's client connects -- matched by
|
|
process group at claim time, not by order; tab-group members and external
|
|
clients claim later. A close must signal the group recorded at claim
|
|
time rather than the launcher's pid. */
|
|
pid_t pgid;
|
|
/* PID of the *connected* client (from claim-time credentials). Used as
|
|
the kill target when the client belongs to a process group we do not
|
|
own (e.g. one started from the compositor's own shell/terminal), where
|
|
signaling the whole group would kill the launcher's session too. */
|
|
pid_t client_pid;
|
|
struct wlr_xdg_toplevel *toplevel;
|
|
#if HAVE_XWAYLAND
|
|
/* X11 tab: an Xwayland surface instead of an xdg toplevel (toplevel is
|
|
NULL, is_x11 set). Shared tab logic goes through the app_* view
|
|
helpers; only claim/scene wiring differs per surface kind. */
|
|
bool is_x11;
|
|
struct wlr_xwayland_surface *xsurface;
|
|
#endif
|
|
/* Container for this tab's whole window stack: the main surface and
|
|
every stacked tab-group member hang off it as siblings. Disabled whenever
|
|
the tab is hidden; show_current() keeps exactly one child enabled so
|
|
only the current window is drawn or hit-tested. */
|
|
struct wlr_scene_tree *stack;
|
|
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;
|
|
#if HAVE_XWAYLAND
|
|
/* Extra X11-only listeners: surface association and the ICCCM requests
|
|
with no xdg counterpart. map/commit/destroy/request_maximize/
|
|
request_fullscreen/request_minimize are shared with the xdg path. */
|
|
struct wl_listener xs_associate;
|
|
struct wl_listener xs_dissociate;
|
|
struct wl_listener xs_request_configure;
|
|
struct wl_listener xs_request_activate;
|
|
struct wl_listener xs_request_close;
|
|
struct wl_listener xs_set_title;
|
|
struct wl_listener xs_set_class;
|
|
/* map/commit listen on the associated wlr_surface; tracked so destroy
|
|
never removes links that dissociate already removed. */
|
|
bool xs_surface_linked;
|
|
#endif
|
|
|
|
/* Resolved overlay icon (a GL texture in host pixel space). */
|
|
GLuint icon_tex;
|
|
int icon_w, icon_h;
|
|
|
|
/* Resolved overlay icon for standalone mode (a wlroots texture). */
|
|
struct wlr_texture *icon_wlr;
|
|
bool icon_resolved; /* true once a load attempt has been made */
|
|
|
|
/* Last cursor-shape-v1 shape requested by this app's client (0 = none
|
|
requested yet). Reset against the active tab on focus change so a
|
|
switched-from tab's shape never bleeds onto the next tab. */
|
|
int cursor_shape;
|
|
|
|
/* Slot awaiting its toplevel. True from creation until a client claims
|
|
the slot in handle_new_toplevel (matched by launch process group), so
|
|
a CLI tab or a fresh Alt+Shift+T terminal is live-but-black while its
|
|
command starts: it shows in the overlay with its command line as the
|
|
title, and cycling can land on it (backdrop + title). Cleared on
|
|
claim, and again when a black tab is closed. A slot without a
|
|
toplevel and without pending_claim would only exist transiently, so
|
|
closed tabs leave no holes: remove_app() drops them outright. */
|
|
bool pending_claim;
|
|
|
|
/* Where a tab's slot came from. ORIGIN_CLI slots hold the command-line
|
|
commands and are forked all at once at startup (see launch_all_cli),
|
|
so the tab order mirrors the command line. ORIGIN_ADD_TAB is an
|
|
Alt+Shift+T terminal. ORIGIN_GROUP is a second toplevel from an
|
|
already-connected client and is glued right of its parent tab.
|
|
ORIGIN_EXTERNAL is a client we did not launch (e.g. started from our
|
|
own shell). */
|
|
enum { ORIGIN_CLI, ORIGIN_ADD_TAB, ORIGIN_GROUP, ORIGIN_EXTERNAL } origin;
|
|
/* For ORIGIN_GROUP: the tab owning the connected client this window
|
|
belongs to (the "parent" tab its slot is glued right of). */
|
|
struct kick_app *parent_app;
|
|
|
|
/* The user closed this tab (Alt+Delete). While set, cycle_tab skips the
|
|
slot and the SIGKILL escalation timer (close_timer) may still be armed;
|
|
cleared once the toplevel is destroyed or the slot is reused. */
|
|
bool closing;
|
|
struct wl_event_source *close_timer;
|
|
|
|
/* The tab's window stack: the wrapped main surface first (see
|
|
group_wrap_main), then any stacked tab-group members (--tab-groups
|
|
stack) oldest first, newest arrivals at the tail. focus_member is
|
|
the member that currently holds the keyboard while this tab is
|
|
active -- the rotation's cursor; it always points at a member
|
|
once the tab has one to show. */
|
|
struct wl_list group_members;
|
|
struct kick_tab_group_member *focus_member;
|
|
|
|
/* Last overlay row title / icon app_id drawn for this tab. The row
|
|
shows whatever member the tab is currently displaying, so these
|
|
caches let the overlay log one line per actual change and
|
|
re-resolve the icon when the effective app_id moves underneath. */
|
|
char row_title[128];
|
|
char icon_app_id[128];
|
|
};
|
|
|
|
/* A stacked tab-group member: a second toplevel from a client whose tab already
|
|
exists (--tab-groups on). Its scene tree is a sibling of the owner
|
|
tab's main surface under the tab's stack container, so it shows and
|
|
hides with the tab; show_current() keeps only the current window's node
|
|
enabled, so the sub paints alone while it is on top. */
|
|
struct kick_tab_group_member {
|
|
/* True for the wrapper that represents a tab's own main surface in
|
|
its window stack: every rotation/visibility/title path then walks
|
|
one plain list of windows with no privileged entry. The wrapper
|
|
carries no listeners -- the tab-level map/commit/destroy handlers
|
|
stay authoritative -- and its liveness mirrors app_has_window(). */
|
|
bool is_main;
|
|
struct wlr_xdg_toplevel *toplevel;
|
|
#if HAVE_XWAYLAND
|
|
bool is_x11;
|
|
struct wlr_xwayland_surface *xsurface;
|
|
#endif
|
|
struct kick_app *owner;
|
|
struct wlr_scene_tree *tree;
|
|
bool mapped;
|
|
/* Per-window overlay icon: each tab-group window shows its own icon,
|
|
mirrored from struct kick_app's icon fields. */
|
|
GLuint icon_tex;
|
|
int icon_w, icon_h;
|
|
struct wlr_texture *icon_wlr;
|
|
bool icon_resolved;
|
|
char icon_app_id[128];
|
|
struct wl_listener destroy;
|
|
struct wl_listener map;
|
|
struct wl_listener unmap;
|
|
struct wl_listener commit;
|
|
#if HAVE_XWAYLAND
|
|
struct wl_listener xs_associate;
|
|
bool xs_surface_linked;
|
|
#endif
|
|
struct wl_list link; /* owner->group_members */
|
|
};
|
|
|
|
struct kick_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;
|
|
struct wlr_scene_rect *bg_rect;
|
|
/* Transparent full-output sentinel used to force whole-output damage on
|
|
overlay transitions; see standalone_overlay_damage(). */
|
|
struct wlr_scene_rect *overlay_damage_rect;
|
|
/* Standalone-only cursor: a real wlr_cursor so the pointer is visible
|
|
(hardware cursor plane on DRM, scene-composited on headless). */
|
|
struct wlr_cursor *cursor;
|
|
struct wlr_xcursor_manager *xcursor_mgr;
|
|
struct wlr_output_layout *output_layout;
|
|
struct wlr_seat *seat;
|
|
struct wlr_xdg_shell *xdg_shell;
|
|
/* Kept for wlr_xwayland_create(), which needs the compositor global. */
|
|
struct wlr_compositor *compositor;
|
|
#if HAVE_XWAYLAND
|
|
/* XWayland: wlroots' XWM plus a lazy Xwayland server (spawned on the
|
|
first X11 client connect). or_windows holds the override-redirect
|
|
windows (menus, tooltips) that float above the tab trees. */
|
|
struct wlr_xwayland *xwayland;
|
|
struct wl_listener xwayland_new_surface;
|
|
struct wl_listener xwayland_ready;
|
|
struct wl_listener xwayland_destroy;
|
|
struct wl_list or_windows;
|
|
/* The tab whose surface held the keyboard before an override-redirect
|
|
window took it; restored when that window unmaps. */
|
|
struct kick_app *or_focus_owner;
|
|
#endif
|
|
/* Client cursor-shape-v1 requests (wlr_cursor_shape_manager_v1). The
|
|
manager is passive: it emits request_set_shape and we either apply the
|
|
shape to our standalone cursor or forward it to the host pointer. */
|
|
struct wlr_cursor_shape_manager_v1 *shape_mgr;
|
|
struct wl_listener shape_listener;
|
|
/* Protocol managers that only need to outlive the display: presentation
|
|
(frame pacing), xdg-output (logical output geometry) and viewporter
|
|
(wl_viewport buffer scaling). Kept as fields so the globals are not
|
|
garbage collected while clients still hold resources. */
|
|
struct wlr_presentation *presentation;
|
|
struct wlr_xdg_output_manager_v1 *xdg_output_mgr;
|
|
struct wlr_viewporter *viewporter;
|
|
/* Primary selection (zwp_primary_selection_v1). Clients request the
|
|
selection on their seat device; the request handler below is what
|
|
actually installs it on the seat, mirroring the clipboard path. */
|
|
struct wlr_primary_selection_v1_device_manager *primary_selection_mgr;
|
|
struct wl_listener request_set_primary_selection;
|
|
/* Pointer constraints + relative pointer (zwp_pointer_constraints_v1 /
|
|
zwp_relative_pointer_v1). Only the focused surface's constraint is
|
|
active; its region lives in the surface's local space, so the surface's
|
|
scene origin is cached when the constraint activates. */
|
|
struct wlr_pointer_constraints_v1 *pointer_constraints;
|
|
struct wlr_relative_pointer_manager_v1 *relative_pointer_mgr;
|
|
struct wlr_pointer_constraint_v1 *active_constraint;
|
|
struct wl_listener constraint_destroy;
|
|
struct wl_listener seat_pointer_focus;
|
|
double constraint_ox, constraint_oy;
|
|
double lock_x, lock_y;
|
|
double last_ptr_x, last_ptr_y;
|
|
/* Last cursor shape applied (standalone) or forwarded (nested), as a
|
|
wp_cursor_shape_device_v1 shape value; 0 = nothing applied yet. */
|
|
int cursor_shape;
|
|
struct wlr_keyboard keyboard;
|
|
struct wlr_swapchain *swapchain;
|
|
/* Self-pipe used to wake the idle event loop from the SIGUSR1 dump
|
|
handler (async-signal-safe write); the drain callback runs in the main
|
|
loop and the render gate consumes needs_redraw. */
|
|
struct wl_event_source *sigpipe_src;
|
|
int sig_pipe[2];
|
|
|
|
struct wl_listener new_output;
|
|
struct wl_listener new_toplevel;
|
|
struct wl_listener new_popup;
|
|
struct wl_listener frame;
|
|
struct wl_listener output_destroy;
|
|
struct wl_event_source *ctl_source;
|
|
|
|
/* The active tab, by slot. Set to the last command-line command's slot
|
|
at startup, so that tab is active -- black, with its command line as
|
|
the overlay title -- from the very first frame and the user only waits
|
|
for it. A slot that is active while still pending (no toplevel yet)
|
|
shows the black backdrop and clear seat focus until it maps. */
|
|
struct kick_app *active_app;
|
|
struct kick_app **apps;
|
|
int num_apps;
|
|
int apps_cap;
|
|
|
|
struct xkb_context *xkb_ctx;
|
|
struct xkb_keymap *xkb_keymap;
|
|
struct xkb_state *xkb_state;
|
|
/* Modifier index of the tab-management prefix (Alt or Super/Mod4,
|
|
per --modifier). */
|
|
xkb_mod_index_t xkb_prefix_mod;
|
|
|
|
/* A client's fullscreen request is honored and mirrored onto the host
|
|
window, and vice versa. */
|
|
bool clients_fullscreen;
|
|
|
|
/* Standalone (real output) mode: the output is driven directly by a
|
|
native backend (DRM/headless) instead of being blitted into a host
|
|
window. Set from KICK_MODE=standalone or a forced WLR_BACKENDS. */
|
|
bool standalone;
|
|
bool standalone_headless;
|
|
struct wlr_session *session;
|
|
struct wl_listener new_input;
|
|
struct wlr_texture *overlay_atlas_wlr; /* standalone: overlaid atlas */
|
|
|
|
/* Standalone native keyboard devices (one entry per libinput keyboard;
|
|
see standalone_kbdev below). */
|
|
struct wl_list kbdevs;
|
|
/* Standalone native pointer devices (one entry per libinput pointer;
|
|
see standalone_ptrdev below). */
|
|
struct wl_list ptrdevs;
|
|
};
|
|
|
|
/* Requested output resolution from --screen-mode WxH[@Hz]; 0/0/0 means "let
|
|
the output pick its preferred mode". Refresh is stored in mHz. */
|
|
static int mode_req_w, mode_req_h;
|
|
static int mode_req_refresh;
|
|
|
|
/* The command-line command to start focused (--start-cmd-index), 0-based as
|
|
soon as the (1-based) user value is parsed. -1 (unset) means the last
|
|
command, keeping the startup contract. */
|
|
static int start_cmd_index = -1;
|
|
|
|
/* ===================== Host-side (nested) state ===================== */
|
|
|
|
/* One cached ASCII glyph in the overlay's font atlas texture. */
|
|
struct overlay_glyph {
|
|
int ax, ay; /* atlas pixel origin */
|
|
int pw, ph; /* glyph bitmap size */
|
|
int adv; /* advance width, pixels */
|
|
int top; /* y offset of the glyph top from the row top */
|
|
};
|
|
|
|
struct kick_host {
|
|
struct wl_display *display;
|
|
struct wl_registry *registry;
|
|
struct wl_compositor *compositor;
|
|
struct wl_surface *surface;
|
|
struct xdg_wm_base *wm_base;
|
|
/* The nested window is a libdecor frame around our wl_surface: libdecor
|
|
owns the xdg_surface/xdg_toplevel plus the window decorations,
|
|
negotiating server-side decoration when the host offers it and drawing
|
|
its own titlebar (with move/resize/close) otherwise (GNOME). */
|
|
struct libdecor *libdecor;
|
|
struct libdecor_frame *frame;
|
|
struct wl_seat *seat;
|
|
struct wl_keyboard *keyboard;
|
|
struct wl_pointer *pointer;
|
|
/* Host cursor-shape-v1: forward a nested client's requested cursor shape
|
|
to the host compositor (which draws the cursor over our window). The
|
|
device binds to the host pointer and goes inert with it, so it is
|
|
recreated whenever pointer capability is re-gained. */
|
|
struct wp_cursor_shape_manager_v1 *shape_mgr;
|
|
struct wp_cursor_shape_device_v1 *shape_dev;
|
|
uint32_t ptr_serial; /* latest host wl_pointer.enter serial */
|
|
uint32_t seat_name; /* registry name of the bound wl_seat global */
|
|
bool registry_synced; /* initial registry scan completed */
|
|
bool configured;
|
|
bool fullscreen;
|
|
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;
|
|
int32_t axis_discrete[2];
|
|
/* GL program */
|
|
GLuint gl_program;
|
|
GLint gl_pos_loc, gl_uv_loc, gl_tex_loc, gl_color_loc;
|
|
GLuint import_tex;
|
|
/* Alt-held tab-title overlay */
|
|
bool overlay_visible;
|
|
GLuint overlay_atlas_tex;
|
|
GLuint overlay_white_tex;
|
|
int overlay_atlas_w, overlay_atlas_h;
|
|
struct overlay_glyph overlay_glyphs[95]; /* chars 32..126 */
|
|
int overlay_font_h; /* ascent + descent, pixels */
|
|
/* Present gating: at most one buffer aimed at the host compositor at a
|
|
time, paced by the host surface's wl_callback. */
|
|
struct wl_callback *frame_cb;
|
|
bool present_pending;
|
|
bool back_dirty;
|
|
/* 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;
|
|
/* What each repeat tick should do: REPEAT_CYCLE switches tabs,
|
|
REPEAT_REORDER moves the active tab within the list, REPEAT_SUBCYCLE
|
|
rotates the active tab's stacked windows. */
|
|
enum { REPEAT_CYCLE, REPEAT_REORDER, REPEAT_SUBCYCLE } repeat_kind;
|
|
};
|
|
|
|
static struct kick_server server;
|
|
static struct kick_host host;
|
|
static char kick_socket[64];
|
|
static char socket_path[256];
|
|
static volatile sig_atomic_t needs_redraw;
|
|
static bool frame_pending;
|
|
static bool shutting_down;
|
|
|
|
/* Terminal launched for the Alt+Shift+T "new tab" binding. Defaults to
|
|
alacritty (sane no-config font size, unlike foot's 8pt); overridable via
|
|
the $TERMINAL environment variable. The value is used verbatim by sh -c,
|
|
so it may carry arguments. */
|
|
static const char *term_cmd = "alacritty";
|
|
static bool term_key_held; /* Alt+Shift+T currently down (repeat latch) */
|
|
static bool delete_key_held; /* prefix+Delete currently down (repeat latch) */
|
|
|
|
/* The modifier prefix for the tab-management chords: hold = show overlay,
|
|
Up/Down = cycle, Shift+Up/Down = reorder, Delete = close. Defaults to
|
|
Alt; --modifier super selects Super (Mod4), which is a good fit for
|
|
standalone/DRM and labwc-style hosts that do not reserve it the way
|
|
GNOME does. */
|
|
enum kick_prefix { PREFIX_ALT, PREFIX_SUPER };
|
|
static enum kick_prefix tab_prefix = PREFIX_ALT;
|
|
|
|
/* --tab-groups: how a related window from an already-connected client groups
|
|
with its tab. A window is "related" when its client process is a (direct or
|
|
indirect) child of that tab's client process (process ancestry, walked up
|
|
/proc). 'on' (default) stacks related windows above the parent tab's
|
|
surface -- they share the tab's visibility and take keyboard focus while
|
|
that tab is shown, and never appear in cycling or the overlay (a
|
|
browser-style tab group); 'off' gives each related window its own tab. */
|
|
static bool tab_groups = true;
|
|
|
|
/* ===================== Clipboard bridge ===================== */
|
|
|
|
enum bridge_backend {
|
|
BRIDGE_BACKEND_NONE = 0,
|
|
BRIDGE_BACKEND_DATA_CONTROL,
|
|
BRIDGE_BACKEND_WL_DATA_DEVICE,
|
|
};
|
|
|
|
/* Bridges the nested seat's selection 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; its v2 set_primary_selection /
|
|
primary_selection pair carries the primary (middle-click) selection.
|
|
- 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.
|
|
It has no primary selection, so the primary bridge only exists on the
|
|
data-control backend.
|
|
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;
|
|
struct wl_listener set_primary_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 */
|
|
|
|
/* the nested primary-selection source currently mirrored to the host;
|
|
serves host primary-selection requests (middle click) until destroyed */
|
|
struct wlr_primary_selection_source *inner_primary_src;
|
|
struct wl_listener inner_primary_src_destroy;
|
|
bool inner_primary_clear_requested;
|
|
bool primary_ignore_next_inner; /* one-shot guard for host->nested echoes */
|
|
|
|
/* 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;
|
|
/* host primary selection over the same data-control device (v2): our
|
|
published source, the current host offer, and the echo of our own set */
|
|
struct zwlr_data_control_source_v1 *dc_primary_source;
|
|
struct zwlr_data_control_offer_v1 *dc_primary_offer;
|
|
struct zwlr_data_control_offer_v1 *dc_primary_echo_offer;
|
|
bool dc_primary_supported; /* bound data-control version >= 2 */
|
|
|
|
/* MIME list currently being collected from a host offer */
|
|
char **mimes;
|
|
size_t mimes_len, mimes_cap;
|
|
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 */
|
|
bool dc_primary_pending_echo; /* our own primary set: echo not yet consumed */
|
|
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) */
|
|
|
|
/* Eager text prefetch for the mirrored primary selection. Kept separate
|
|
from the clipboard prefetch above: the host can change either selection
|
|
on its own, so their buffers must never collide. */
|
|
int primary_prefetch_fd;
|
|
struct wl_event_source *primary_prefetch_src;
|
|
char *primary_prefetch_buf;
|
|
size_t primary_prefetch_len, primary_prefetch_cap;
|
|
char primary_prefetch_mime[64];
|
|
bool primary_prefetch_ready;
|
|
};
|
|
|
|
static struct clipboard_bridge bridge = {
|
|
.prefetch_fd = -1,
|
|
.primary_prefetch_fd = -1,
|
|
};
|
|
|
|
static void bridge_cleanup(void);
|
|
static void bridge_init(void);
|
|
static void bridge_attach_seat(void);
|
|
|
|
/* Frame dumps: SIGUSR1 (or KICK_AUTODUMP) requests a scene-buffer save
|
|
(client content as wlroots drew it) and a host back-buffer save (what we
|
|
composited for the host) on the next frame. */
|
|
static volatile sig_atomic_t dump_scene_req, dump_host_req;
|
|
static int dump_seq;
|
|
static bool client_dumped = false;
|
|
|
|
/* Set by sigchld_handler whenever a child exits; the main loop clears it and
|
|
runs sweep_dead_startup_groups() to drop black tabs whose command exited
|
|
without ever connecting. A plain flag (set + cleared in the loop) keeps
|
|
the reaping work out of the signal handler. */
|
|
static volatile sig_atomic_t sigchld_seen = 0;
|
|
|
|
/* The most recently reaped child, recorded by sigchld_handler so the startup
|
|
sweep can tell the user WHY a tab's command died (usage error vs crash vs
|
|
signal). Best-effort by design: only one exit is kept, so a burst of child
|
|
exits can overwrite the entry before the sweep reads it -- the message then
|
|
just omits the status. sig_atomic_t holds pids on Linux (pid_t is int). */
|
|
static volatile sig_atomic_t last_child_pid = -1;
|
|
static volatile sig_atomic_t last_child_status = 0;
|
|
|
|
/* A tab is "live" when it can own the show: it has a mapped toplevel, or
|
|
it is pending (black) -- an unclaimed CLI tab at startup or a fresh
|
|
Alt+Shift+T terminal. Slots are otherwise removed outright, so a live
|
|
check is all that's needed to know whether a slot should be shown,
|
|
cycled to, or handed the active crown. */
|
|
static bool app_live(struct kick_app *app);
|
|
/* The currently focused toplevel (NULL while the active slot is black). */
|
|
static struct wlr_xdg_toplevel *active_toplevel(void);
|
|
static void set_active_app(struct kick_app *app);
|
|
static void set_active_surface(struct wlr_xdg_toplevel *toplevel);
|
|
static void render_and_present(void);
|
|
|
|
/* Request a redraw from a non-client source (kick's own UI: tab switches,
|
|
reorder, resize, overlay, boot). Marks the frame dirty and wakes the
|
|
event loop via wlr_output_schedule_frame so an idle compositor still
|
|
renders promptly; the drain of that wake presents on the next frame-ready
|
|
point instead of racing an in-flight page flip. */
|
|
static void kick_request_redraw(void) {
|
|
needs_redraw = true;
|
|
if (server.output)
|
|
wlr_output_schedule_frame(server.output);
|
|
}
|
|
static struct kick_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel);
|
|
static void host_teardown_window(void);
|
|
static int app_index(struct kick_app *app);
|
|
static struct kick_tab_group_member *top_mapped_member(struct kick_app *app);
|
|
static bool member_mapped(struct kick_tab_group_member *m);
|
|
static struct kick_app *pick_successor(struct kick_app *app,
|
|
int prefer_right);
|
|
static void remove_app(int idx);
|
|
static int add_tab(const char *cmd);
|
|
static int cmd_in_path(const char *cmd);
|
|
static void insert_app_after(struct kick_app *app, struct kick_app *anchor);
|
|
static void launch_client(struct kick_app *app);
|
|
static void launch_all_cli(void);
|
|
|
|
#if HAVE_XWAYLAND
|
|
/* XWayland surface handlers (defined with the other toplevel handlers). */
|
|
static void handle_xwayland_new_surface(struct wl_listener *listener,
|
|
void *data);
|
|
static void handle_xwayland_ready(struct wl_listener *listener, void *data);
|
|
static void handle_xwayland_destroy(struct wl_listener *listener, void *data);
|
|
#endif
|
|
|
|
/* Cursor-shape-v1: apply a wp_cursor_shape_device_v1 shape value -- setting
|
|
the standalone cursor image or forwarding it to the host pointer. */
|
|
static void apply_cursor_shape(int shape);
|
|
static void handle_shape_set(struct wl_listener *listener, void *data);
|
|
static void host_cursor_shape_get_pointer(void);
|
|
static void host_cursor_shape_set(int shape);
|
|
|
|
/* Logging levels: quiet by default so a plain launch writes nothing to the
|
|
launching terminal except fatal errors. KICK_LOG=1 shows status lines,
|
|
KICK_LOG=2 adds the per-frame [diag] trace. SIGUSR1 and KICK_AUTODUMP
|
|
dumps always write their .ppm files regardless of level. */
|
|
enum { KICK_LOG_QUIET = 0, KICK_LOG_INFO = 1, KICK_LOG_DEBUG = 2 };
|
|
|
|
static int kick_log_level(void) {
|
|
static volatile sig_atomic_t level = -1;
|
|
if (level < 0) {
|
|
const char *v = getenv("KICK_LOG");
|
|
int l = v ? atoi(v) : 0;
|
|
if (l < KICK_LOG_QUIET) l = KICK_LOG_QUIET;
|
|
if (l > KICK_LOG_DEBUG) l = KICK_LOG_DEBUG;
|
|
level = l;
|
|
}
|
|
return level;
|
|
}
|
|
|
|
static void kick_vlog(int level, const char *fmt, va_list args) {
|
|
if (level > kick_log_level())
|
|
return;
|
|
vfprintf(stderr, fmt, args);
|
|
}
|
|
|
|
static void kick_log(int level, const char *fmt, ...) {
|
|
va_list args;
|
|
va_start(args, fmt);
|
|
kick_vlog(level, fmt, args);
|
|
va_end(args);
|
|
}
|
|
|
|
/* Route libxkbcommon's own log output (which otherwise writes
|
|
"xkbcommon: ERROR: ..." straight to stderr, bypassing KICK_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 kick_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) ? KICK_LOG_INFO : KICK_LOG_DEBUG;
|
|
if (l > kick_log_level())
|
|
return;
|
|
fputs("kick: xkbcommon: ", stderr);
|
|
vfprintf(stderr, fmt, args);
|
|
}
|
|
|
|
static void kick_log_level_init(void) {
|
|
(void)kick_log_level();
|
|
}
|
|
|
|
/* The process group to signal for an app: the group its claimed client lives
|
|
in (set at claim time), falling back to the launcher pid for slots with no
|
|
client yet. */
|
|
static pid_t app_signal_group(struct kick_app *app) {
|
|
return (app && app->pgid > 0) ? app->pgid : (app ? app->pid : -1);
|
|
}
|
|
|
|
/* Whether an app has a killable target: either a process group we own, or a
|
|
connected client pid (external clients sharing our own group). */
|
|
static bool app_killable(struct kick_app *app) {
|
|
return app && (app_signal_group(app) > 0 || app->client_pid > 0);
|
|
}
|
|
|
|
/* Signal an app's process target. A group we created (launch_client runs the
|
|
child in its own setpgid'd group) is signaled as a group so the whole client
|
|
tree dies. A client we did NOT create, or one sharing our own process group
|
|
(e.g. started from the same terminal as the compositor), is signaled by pid
|
|
alone: group-signaling it would kill the launcher's session, ourselves
|
|
included. */
|
|
static void kill_app(struct kick_app *app, int sig) {
|
|
pid_t g = app_signal_group(app);
|
|
if (g > 0 && g != getpgid(0))
|
|
kill(-g, sig);
|
|
else if (app && app->client_pid > 0)
|
|
kill(app->client_pid, sig);
|
|
}
|
|
|
|
/* 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++)
|
|
kill_app(server.apps[i], SIGTERM);
|
|
struct timespec wait = { .tv_sec = 0, .tv_nsec = 150000000 };
|
|
nanosleep(&wait, NULL);
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
kill_app(server.apps[i], SIGKILL);
|
|
}
|
|
|
|
/* 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);
|
|
}
|
|
|
|
static void overlay_font_fini(void);
|
|
static void standalone_overlay_damage(void);
|
|
|
|
/* Standalone native keyboard: one struct per libinput keyboard device, so
|
|
modifier state is always read from the device that emitted the event (the
|
|
legacy single-slot approach broke Shift/Caps when a host exposed several
|
|
keyboards, e.g. a consumer-control node alongside the real one). */
|
|
struct standalone_kbdev {
|
|
struct wlr_keyboard *keyboard;
|
|
struct wl_listener key;
|
|
struct wl_listener modifiers;
|
|
struct wl_listener destroy;
|
|
struct wl_list link;
|
|
};
|
|
|
|
/* Standalone native pointer: one struct per libinput pointer device. Each
|
|
device owns its listeners so a device going away (libinput suspends every
|
|
device when the session loses its VT) can detach them before wlroots
|
|
asserts the event lists are empty, and so all pointers -- not just the
|
|
last-attached one -- reach the seat. */
|
|
struct standalone_ptrdev {
|
|
struct wlr_pointer *pointer;
|
|
struct wl_listener motion;
|
|
struct wl_listener motion_absolute;
|
|
struct wl_listener button;
|
|
struct wl_listener axis;
|
|
struct wl_listener frame;
|
|
struct wl_listener destroy;
|
|
struct wl_list link;
|
|
};
|
|
|
|
/* 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 kick_app **grown =
|
|
realloc(server.apps, (size_t)n * sizeof(*grown));
|
|
if (!grown) return -1;
|
|
server.apps = grown;
|
|
server.apps_cap = n;
|
|
return 0;
|
|
}
|
|
|
|
/* ===================== View helpers (xdg / X11) ===================== */
|
|
/* A tab's window is an xdg toplevel or, with XWayland, an Xwayland surface.
|
|
These helpers keep the shared tab logic (liveness, focus, overlay titles,
|
|
sizing, fullscreen) identical for both; the X11 branches compile away
|
|
without HAVE_XWAYLAND. */
|
|
|
|
static bool app_has_window(struct kick_app *app) {
|
|
if (!app) return false;
|
|
if (app->toplevel) return true;
|
|
#if HAVE_XWAYLAND
|
|
return app->xsurface != NULL;
|
|
#endif
|
|
return false;
|
|
}
|
|
|
|
/* Whether the tab's window stack is currently shown (its container node
|
|
enabled). Independent of WHICH window inside it is current. */
|
|
static bool app_visible(struct kick_app *app) {
|
|
return app && app->stack && app->stack->node.enabled;
|
|
}
|
|
|
|
static struct wlr_surface *app_surface(struct kick_app *app) {
|
|
if (!app) return NULL;
|
|
if (app->toplevel) return app->toplevel->base->surface;
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11)
|
|
return app->xsurface ? app->xsurface->surface : NULL;
|
|
#endif
|
|
return NULL;
|
|
}
|
|
|
|
static const char *app_title(struct kick_app *app) {
|
|
if (!app) return NULL;
|
|
if (app->toplevel) return app->toplevel->title;
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11)
|
|
return app->xsurface ? app->xsurface->title : NULL;
|
|
#endif
|
|
return NULL;
|
|
}
|
|
|
|
static void app_set_activated(struct kick_app *app, bool on) {
|
|
if (!app) return;
|
|
if (app->toplevel) {
|
|
wlr_xdg_toplevel_set_activated(app->toplevel, on);
|
|
return;
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11 && app->xsurface)
|
|
wlr_xwayland_surface_activate(app->xsurface, on);
|
|
#endif
|
|
}
|
|
|
|
static void app_set_maximized(struct kick_app *app, bool on) {
|
|
if (!app) return;
|
|
if (app->toplevel) {
|
|
wlr_xdg_toplevel_set_maximized(app->toplevel, on);
|
|
return;
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11 && app->xsurface)
|
|
wlr_xwayland_surface_set_maximized(app->xsurface, on, on);
|
|
#endif
|
|
}
|
|
|
|
static void app_set_fullscreen(struct kick_app *app, bool on) {
|
|
if (!app) return;
|
|
if (app->toplevel) {
|
|
wlr_xdg_toplevel_set_fullscreen(app->toplevel, on);
|
|
return;
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11 && app->xsurface)
|
|
wlr_xwayland_surface_set_fullscreen(app->xsurface, on);
|
|
#endif
|
|
}
|
|
|
|
/* Keep a mapped window at the maximized output size. XDG clients are told
|
|
via set_size; X11 clients get a configure to the full output at 0,0. */
|
|
static void app_set_size(struct kick_app *app, int width, int height) {
|
|
if (!app) return;
|
|
if (app->toplevel) {
|
|
wlr_xdg_toplevel_set_size(app->toplevel, width, height);
|
|
return;
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11 && app->xsurface && app->xsurface->surface)
|
|
wlr_xwayland_surface_configure(app->xsurface, 0, 0,
|
|
(uint16_t)width, (uint16_t)height);
|
|
#endif
|
|
}
|
|
|
|
/* Whether the underlying protocol object accepts state: xdg surfaces only
|
|
after base->initialized; X11 surfaces once associated with a wlr_surface. */
|
|
static bool app_initialized(struct kick_app *app) {
|
|
if (!app) return false;
|
|
if (app->toplevel) return app->toplevel->base->initialized;
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11)
|
|
return app->xsurface && app->xsurface->surface != NULL;
|
|
#endif
|
|
return false;
|
|
}
|
|
|
|
/* Graceful close for the surface kind; XDG tabs keep their signal-only
|
|
close path, so this is only non-trivial for X11 (WM_DELETE_WINDOW). */
|
|
static void app_graceful_close(struct kick_app *app) {
|
|
#if HAVE_XWAYLAND
|
|
if (app && app->is_x11 && app->xsurface)
|
|
wlr_xwayland_surface_close(app->xsurface);
|
|
#endif
|
|
}
|
|
|
|
static struct wlr_surface *member_surface(struct kick_tab_group_member *sub) { if (!sub) return NULL;
|
|
if (sub->toplevel) return sub->toplevel->base->surface;
|
|
#if HAVE_XWAYLAND
|
|
if (sub->is_x11)
|
|
return sub->xsurface ? sub->xsurface->surface : NULL;
|
|
#endif
|
|
return NULL;
|
|
}
|
|
|
|
static const char *member_title(struct kick_tab_group_member *sub) {
|
|
if (!sub) return NULL;
|
|
if (sub->toplevel) return sub->toplevel->title;
|
|
#if HAVE_XWAYLAND
|
|
if (sub->is_x11)
|
|
return sub->xsurface ? sub->xsurface->title : NULL;
|
|
#endif
|
|
return NULL;
|
|
}
|
|
|
|
static const char *member_app_id(struct kick_tab_group_member *sub) {
|
|
if (!sub) return NULL;
|
|
if (sub->toplevel) return sub->toplevel->app_id;
|
|
#if HAVE_XWAYLAND
|
|
if (sub->is_x11)
|
|
return sub->xsurface ? sub->xsurface->class : NULL;
|
|
#endif
|
|
return NULL;
|
|
}
|
|
|
|
static void member_set_activated(struct kick_tab_group_member *sub, bool on) {
|
|
if (!sub) return;
|
|
if (sub->toplevel) {
|
|
wlr_xdg_toplevel_set_activated(sub->toplevel, on);
|
|
return;
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
if (sub->is_x11 && sub->xsurface)
|
|
wlr_xwayland_surface_activate(sub->xsurface, on);
|
|
#endif
|
|
}
|
|
|
|
static bool app_live(struct kick_app *app) {
|
|
return app && (app_has_window(app) || app->pending_claim);
|
|
}
|
|
|
|
static struct wlr_xdg_toplevel *active_toplevel(void) {
|
|
return server.active_app ? server.active_app->toplevel : NULL;
|
|
}
|
|
|
|
/* 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++) {
|
|
if (host.egl_ok && server.apps[i]->icon_tex) {
|
|
glDeleteTextures(1, &server.apps[i]->icon_tex);
|
|
server.apps[i]->icon_tex = 0;
|
|
}
|
|
if (server.apps[i]->icon_wlr) {
|
|
wlr_texture_destroy(server.apps[i]->icon_wlr);
|
|
server.apps[i]->icon_wlr = NULL;
|
|
}
|
|
struct kick_tab_group_member *m;
|
|
wl_list_for_each(m, &server.apps[i]->group_members, link) {
|
|
if (host.egl_ok && m->icon_tex) {
|
|
glDeleteTextures(1, &m->icon_tex);
|
|
m->icon_tex = 0;
|
|
}
|
|
if (m->icon_wlr) {
|
|
wlr_texture_destroy(m->icon_wlr);
|
|
m->icon_wlr = NULL;
|
|
}
|
|
}
|
|
free(server.apps[i]->cmd);
|
|
free(server.apps[i]);
|
|
}
|
|
free(server.apps);
|
|
server.apps = NULL;
|
|
|
|
/* Detach + free the standalone native keyboard devices. */
|
|
struct standalone_kbdev *kd, *kd_tmp;
|
|
wl_list_for_each_safe(kd, kd_tmp, &server.kbdevs, link) {
|
|
wl_list_remove(&kd->link);
|
|
wl_list_remove(&kd->key.link);
|
|
wl_list_remove(&kd->modifiers.link);
|
|
wl_list_remove(&kd->destroy.link);
|
|
free(kd);
|
|
}
|
|
wl_list_init(&server.kbdevs);
|
|
|
|
/* Detach + free the standalone native pointer devices. */
|
|
struct standalone_ptrdev *pd, *pd_tmp;
|
|
wl_list_for_each_safe(pd, pd_tmp, &server.ptrdevs, link) {
|
|
wl_list_remove(&pd->link);
|
|
wl_list_remove(&pd->motion.link);
|
|
wl_list_remove(&pd->motion_absolute.link);
|
|
wl_list_remove(&pd->button.link);
|
|
wl_list_remove(&pd->axis.link);
|
|
wl_list_remove(&pd->frame.link);
|
|
wl_list_remove(&pd->destroy.link);
|
|
free(pd);
|
|
}
|
|
wl_list_init(&server.ptrdevs);
|
|
|
|
if (server.overlay_atlas_wlr) {
|
|
wlr_texture_destroy(server.overlay_atlas_wlr);
|
|
server.overlay_atlas_wlr = NULL;
|
|
}
|
|
if (server.xkb_state) xkb_state_unref(server.xkb_state);
|
|
if (server.xkb_keymap) xkb_keymap_unref(server.xkb_keymap);
|
|
if (server.xkb_ctx) xkb_context_unref(server.xkb_ctx);
|
|
overlay_font_fini();
|
|
bridge_cleanup();
|
|
remove_socket_file();
|
|
}
|
|
|
|
/* ===================== 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, "kick: 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, "kick: 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"
|
|
"uniform vec4 u_color;\n"
|
|
"void main() {\n"
|
|
" gl_FragColor = texture2D(u_tex, v_uv) * u_color;\n"
|
|
"}\n";
|
|
GLuint vsh = compile_shader(GL_VERTEX_SHADER, vs);
|
|
GLuint fsh = compile_shader(GL_FRAGMENT_SHADER, fs);
|
|
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, "kick: 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");
|
|
host.gl_color_loc = glGetUniformLocation(host.gl_program, "u_color");
|
|
|
|
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);
|
|
|
|
glGenTextures(1, &host.overlay_white_tex);
|
|
glBindTexture(GL_TEXTURE_2D, host.overlay_white_tex);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
|
const GLubyte white_px[4] = { 255, 255, 255, 255 };
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA,
|
|
GL_UNSIGNED_BYTE, white_px);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
return true;
|
|
}
|
|
|
|
/* Blit a source rect of a texture into the window at the given destination
|
|
rect (dst_x, dst_y, dst_w, dst_h, window coords), clipped to the window.
|
|
The source is stretched uniformly to the destination (1:1 when the rects
|
|
match). The draw is tinted by `color` (RGBA), so solid rects are drawn
|
|
from a 1x1 white texture and text glyphs from the font atlas. */
|
|
static void draw_texture_rect(GLuint tex, int tex_w, int tex_h,
|
|
int src_x, int src_y, int src_w, int src_h,
|
|
int dst_x, int dst_y, int dst_w, int dst_h,
|
|
int win_w, int win_h, const GLfloat color[4])
|
|
{
|
|
if (win_w <= 0 || win_h <= 0) return;
|
|
if (src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) return;
|
|
if (src_x >= tex_w || src_y >= tex_h) return;
|
|
if (src_w > tex_w - src_x) src_w = tex_w - src_x;
|
|
if (src_h > tex_h - src_y) src_h = tex_h - src_y;
|
|
int cx = dst_x < 0 ? -dst_x : 0;
|
|
int cy = dst_y < 0 ? -dst_y : 0;
|
|
if (cx >= dst_w || cy >= dst_h) return;
|
|
int wx0 = dst_x + cx;
|
|
int wy0 = dst_y + cy;
|
|
int cw = dst_w - cx;
|
|
int ch = dst_h - cy;
|
|
int maxw = win_w - wx0;
|
|
int maxh = win_h - wy0;
|
|
if (cw > maxw) cw = maxw;
|
|
if (ch > maxh) ch = maxh;
|
|
if (cw <= 0 || ch <= 0) return;
|
|
|
|
float u0 = ((float)src_x + (float)src_w * cx / dst_w) / (float)tex_w;
|
|
float u1 = ((float)src_x + (float)src_w * (cx + cw) / dst_w) / (float)tex_w;
|
|
float v0 = ((float)src_y + (float)src_h * cy / dst_h) / (float)tex_h;
|
|
float v1 = ((float)src_y + (float)src_h * (cy + ch) / dst_h) / (float)tex_h;
|
|
|
|
float nx0 = -1.0f + 2.0f * (float)wx0 / (float)win_w;
|
|
float ny0 = 1.0f - 2.0f * (float)wy0 / (float)win_h;
|
|
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);
|
|
glUniform4fv(host.gl_color_loc, 1, color);
|
|
|
|
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);
|
|
}
|
|
|
|
/* ============ Alt-held tab-title overlay ============
|
|
While the host Alt modifier is held, a passive list of tab titles is
|
|
drawn into the top-left of the host window with the active tab
|
|
highlighted. Up/Down already cycle tabs while Alt is held, so the
|
|
highlight follows server.active_app automatically. Titles are read from
|
|
the wlr_xdg_toplevel at draw time (terminals retitle themselves); a
|
|
still-black tab shows its command line instead. No per-client listeners
|
|
are needed. */
|
|
|
|
static FT_Library overlay_ft_lib;
|
|
static FT_Face overlay_ft_face;
|
|
static bool overlay_font_ok;
|
|
|
|
/* Fixed rasterized font size; 30px is twice the original 15px constant. */
|
|
#define OVERLAY_FONT_PX 30
|
|
|
|
static const char *overlay_pick_font(void) {
|
|
static const char *const paths[] = {
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
|
|
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
|
|
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
|
};
|
|
const char *env = getenv("KICK_FONT");
|
|
if (env && access(env, R_OK) == 0)
|
|
return env;
|
|
for (size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++)
|
|
if (access(paths[i], R_OK) == 0)
|
|
return paths[i];
|
|
return NULL;
|
|
}
|
|
|
|
/* Build the ASCII glyph atlas (chars 32..126) from the chosen system font.
|
|
Must be called with the host EGL context current. Non-fatal: on failure
|
|
the overlay still renders its boxes, just without text. */
|
|
static void overlay_font_init(void) {
|
|
static bool overlay_font_done;
|
|
if (overlay_font_done) return;
|
|
overlay_font_done = true;
|
|
|
|
int px = OVERLAY_FONT_PX;
|
|
static const char *overlay_font_path = NULL;
|
|
if (!overlay_ft_lib) {
|
|
const char *path = overlay_pick_font();
|
|
if (!path) {
|
|
kick_log(KICK_LOG_INFO, "[overlay] no font found; "
|
|
"set KICK_FONT to enable overlay text\n");
|
|
return;
|
|
}
|
|
overlay_font_path = path;
|
|
if (FT_Init_FreeType(&overlay_ft_lib) != 0) {
|
|
kick_log(KICK_LOG_INFO, "[overlay] FreeType init failed\n");
|
|
return;
|
|
}
|
|
if (FT_New_Face(overlay_ft_lib, path, 0, &overlay_ft_face) != 0) {
|
|
kick_log(KICK_LOG_INFO, "[overlay] cannot load font %s\n", path);
|
|
FT_Done_FreeType(overlay_ft_lib);
|
|
overlay_ft_lib = NULL;
|
|
return;
|
|
}
|
|
}
|
|
if (FT_Set_Pixel_Sizes(overlay_ft_face, 0, px) != 0) {
|
|
kick_log(KICK_LOG_INFO, "[overlay] cannot set pixel size %d\n", px);
|
|
return;
|
|
}
|
|
|
|
int height = (int)(overlay_ft_face->size->metrics.height / 64.0f) + 1;
|
|
int asc = (int)(overlay_ft_face->size->metrics.ascender / 64.0f);
|
|
int adv_max = 0;
|
|
for (int c = 32; c <= 126; c++) {
|
|
if (FT_Load_Char(overlay_ft_face, c, FT_LOAD_DEFAULT) == 0) {
|
|
int adv = (int)(overlay_ft_face->glyph->advance.x / 64.0f);
|
|
if (adv > adv_max) adv_max = adv;
|
|
}
|
|
}
|
|
if (adv_max <= 0) adv_max = 8;
|
|
int cell_w = adv_max + 3;
|
|
int cell_h = height + 3;
|
|
int cols = 16;
|
|
int rows = (95 + cols - 1) / cols;
|
|
int atlas_w = cols * cell_w;
|
|
int atlas_h = rows * cell_h;
|
|
|
|
unsigned char *atlas = calloc((size_t)atlas_w * atlas_h * 2, 1);
|
|
if (!atlas) {
|
|
kick_log(KICK_LOG_INFO, "[overlay] atlas alloc failed\n");
|
|
return;
|
|
}
|
|
int n_glyphs = 0;
|
|
for (int c = 32; c <= 126; c++) {
|
|
struct overlay_glyph *g = &host.overlay_glyphs[c - 32];
|
|
if (FT_Load_Char(overlay_ft_face, c, FT_LOAD_RENDER) != 0)
|
|
continue;
|
|
FT_Bitmap *bmp = &overlay_ft_face->glyph->bitmap;
|
|
/* A bitmap wider/taller than its cell would corrupt the glyphs it
|
|
spills into; skip such glyphs rather than damage the atlas. */
|
|
if ((int)bmp->width > cell_w - 2 || (int)bmp->rows > cell_h - 2)
|
|
continue;
|
|
int cell = c - 32;
|
|
int cx = (cell % cols) * cell_w;
|
|
int cy = (cell / cols) * cell_h;
|
|
g->ax = cx + 1;
|
|
g->ay = cy + 1;
|
|
g->pw = bmp->width;
|
|
g->ph = bmp->rows;
|
|
g->adv = (int)(overlay_ft_face->glyph->advance.x / 64.0f);
|
|
g->top = asc - overlay_ft_face->glyph->bitmap_top;
|
|
/* Cells are GL_LUMINANCE_ALPHA, so each 8-bit alpha is duplicated. */
|
|
for (int r = 0; r < (int)bmp->rows; r++) {
|
|
const unsigned char *src = bmp->buffer + (size_t)r * bmp->pitch;
|
|
unsigned char *dst = atlas
|
|
+ ((size_t)(g->ay + r) * atlas_w + g->ax) * 2;
|
|
for (int x = 0; x < (int)bmp->width; x++) {
|
|
dst[x * 2 + 0] = src[x];
|
|
dst[x * 2 + 1] = src[x];
|
|
}
|
|
}
|
|
n_glyphs++;
|
|
}
|
|
if (n_glyphs == 0) {
|
|
free(atlas);
|
|
return;
|
|
}
|
|
|
|
if (server.standalone) {
|
|
/* Standalone has no host GL context: hand the glyph bitmap to the
|
|
wlroots renderer as a premultiplied-white texture (r=g=b=a) so the
|
|
single quad shader can tint it via the pass alpha. */
|
|
unsigned char *rgba = malloc((size_t)atlas_w * atlas_h * 4);
|
|
if (rgba) {
|
|
for (size_t i = 0; i < (size_t)atlas_w * atlas_h; i++) {
|
|
unsigned char v = atlas[i * 2];
|
|
rgba[i * 4 + 0] = v;
|
|
rgba[i * 4 + 1] = v;
|
|
rgba[i * 4 + 2] = v;
|
|
rgba[i * 4 + 3] = v;
|
|
}
|
|
server.overlay_atlas_wlr = wlr_texture_from_pixels(
|
|
server.renderer, DRM_FORMAT_ARGB8888,
|
|
(uint32_t)atlas_w * 4, (uint32_t)atlas_w, (uint32_t)atlas_h,
|
|
rgba);
|
|
}
|
|
free(rgba);
|
|
free(atlas);
|
|
host.overlay_font_h = height;
|
|
overlay_font_ok = server.overlay_atlas_wlr != NULL;
|
|
kick_log(KICK_LOG_INFO, "[overlay] font %s glyphs=%d atlas=%dx%d\n",
|
|
overlay_font_path, n_glyphs, atlas_w, atlas_h);
|
|
return;
|
|
}
|
|
|
|
if (!host.overlay_atlas_tex)
|
|
glGenTextures(1, &host.overlay_atlas_tex);
|
|
glBindTexture(GL_TEXTURE_2D, host.overlay_atlas_tex);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, atlas_w, atlas_h, 0,
|
|
GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, atlas);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
free(atlas);
|
|
host.overlay_atlas_w = atlas_w;
|
|
host.overlay_atlas_h = atlas_h;
|
|
host.overlay_font_h = height;
|
|
overlay_font_ok = true;
|
|
kick_log(KICK_LOG_INFO, "[overlay] font %s glyphs=%d atlas=%dx%d\n",
|
|
overlay_font_path, n_glyphs, atlas_w, atlas_h);
|
|
}
|
|
|
|
static void overlay_font_fini(void) {
|
|
if (overlay_ft_face) {
|
|
FT_Done_Face(overlay_ft_face);
|
|
overlay_ft_face = NULL;
|
|
}
|
|
if (overlay_ft_lib) {
|
|
FT_Done_FreeType(overlay_ft_lib);
|
|
overlay_ft_lib = NULL;
|
|
}
|
|
overlay_font_ok = false;
|
|
}
|
|
|
|
/* ===================== Overlay app icons ===================== */
|
|
|
|
/* Drawn square of the icon slot, in host pixels. */
|
|
#define OVERLAY_ICON_SZ 28
|
|
#ifndef MIN
|
|
#define MIN(a, b) ((a) < (b) ? (a) : (b))
|
|
#endif
|
|
|
|
static char *overlay_strndup(const char *s, size_t n) {
|
|
char *r = malloc(n + 1);
|
|
if (r) {
|
|
memcpy(r, s, n);
|
|
r[n] = '\0';
|
|
}
|
|
return r;
|
|
}
|
|
|
|
static bool overlay_endswith(const char *s, const char *suffix) {
|
|
size_t ls = strlen(s), lx = strlen(suffix);
|
|
return ls >= lx && strcmp(s + ls - lx, suffix) == 0;
|
|
}
|
|
|
|
/* Read the Icon= value (case-sensitive, not the localized Icon[lang]= form)
|
|
out of a .desktop file, malloc'd, or NULL. */
|
|
static char *desktop_icon_from_file(FILE *f) {
|
|
char line[1024];
|
|
while (fgets(line, sizeof(line), f)) {
|
|
if (strncmp(line, "Icon=", 5) != 0)
|
|
continue;
|
|
if (line[5] == '[') /* Icon[locale]= */
|
|
continue;
|
|
size_t n = strlen(line);
|
|
while (n > 5 && (line[n - 1] == '\n' || line[n - 1] == '\r' ||
|
|
line[n - 1] == ' ' || line[n - 1] == '\t'))
|
|
n--;
|
|
return overlay_strndup(line + 5, n - 5);
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* Find `<app_id>.desktop` under an XDG data dir and return its Icon= value,
|
|
or NULL. */
|
|
static char *desktop_icon_name(const char *app_id) {
|
|
const char *data_home = getenv("XDG_DATA_HOME");
|
|
const char *data_dirs = getenv("XDG_DATA_DIRS");
|
|
if (!data_dirs || !data_dirs[0])
|
|
data_dirs = "/usr/local/share:/usr/share";
|
|
char buf[4096];
|
|
|
|
if (data_home && data_home[0]) {
|
|
snprintf(buf, sizeof(buf), "%s/applications/%s.desktop",
|
|
data_home, app_id);
|
|
FILE *f = fopen(buf, "r");
|
|
if (f) {
|
|
char *icon = desktop_icon_from_file(f);
|
|
fclose(f);
|
|
if (icon) return icon;
|
|
}
|
|
}
|
|
char *copy = strdup(data_dirs);
|
|
if (!copy) return NULL;
|
|
for (char *p = copy, *dir; p; p = dir) {
|
|
dir = strchr(p, ':');
|
|
if (dir) *dir++ = '\0';
|
|
if (p[0]) {
|
|
snprintf(buf, sizeof(buf), "%s/applications/%s.desktop", p, app_id);
|
|
FILE *f = fopen(buf, "r");
|
|
if (f) {
|
|
char *icon = desktop_icon_from_file(f);
|
|
fclose(f);
|
|
if (icon) {
|
|
free(copy);
|
|
return icon;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
free(copy);
|
|
return NULL;
|
|
}
|
|
|
|
/* Turn an Icon= value (theme name, bare filename or absolute path) into a
|
|
loadable .png/.svg path, or NULL. */
|
|
static char *overlay_find_icon_file(const char *name) {
|
|
static const char *const themes[] = { "Adwaita", "hicolor", "default" };
|
|
static const char *const sizes[] =
|
|
{ "scalable", "48x48", "32x32", "24x24" };
|
|
static const char *const exts[] = { "png", "svg" };
|
|
char roots[8][512];
|
|
int n_roots = 0;
|
|
const char *data_home = getenv("XDG_DATA_HOME");
|
|
const char *data_dirs = getenv("XDG_DATA_DIRS");
|
|
if (data_home && data_home[0] && n_roots < 8)
|
|
snprintf(roots[n_roots++], sizeof(roots[0]), "%s/icons", data_home);
|
|
if (!data_dirs || !data_dirs[0])
|
|
data_dirs = "/usr/local/share:/usr/share";
|
|
char *copy = strdup(data_dirs);
|
|
for (char *p = copy, *dir; p && n_roots < 8; p = dir) {
|
|
dir = strchr(p, ':');
|
|
if (dir) *dir++ = '\0';
|
|
if (p[0])
|
|
snprintf(roots[n_roots++], sizeof(roots[0]), "%s/icons", p);
|
|
}
|
|
free(copy);
|
|
|
|
if (strchr(name, '/') && access(name, R_OK) == 0 &&
|
|
(overlay_endswith(name, ".png") ||
|
|
overlay_endswith(name, ".svg")))
|
|
return strdup(name);
|
|
|
|
char buf[4096];
|
|
for (int tr = 0; tr < n_roots; tr++) {
|
|
for (size_t t = 0; t < sizeof(themes) / sizeof(themes[0]); t++) {
|
|
for (size_t s = 0; s < sizeof(sizes) / sizeof(sizes[0]); s++) {
|
|
for (size_t e = 0; e < sizeof(exts) / sizeof(exts[0]); e++) {
|
|
snprintf(buf, sizeof(buf), "%s/%s/%s/apps/%s.%s",
|
|
roots[tr], themes[t], sizes[s], name, exts[e]);
|
|
if (access(buf, R_OK) == 0)
|
|
return strdup(buf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
static const char *const pixmap_roots[] = {
|
|
"/usr/share/pixmaps", "/usr/local/share/pixmaps",
|
|
};
|
|
for (size_t pr = 0; pr < sizeof(pixmap_roots) / sizeof(pixmap_roots[0]); pr++) {
|
|
for (size_t e = 0; e < sizeof(exts) / sizeof(exts[0]); e++) {
|
|
snprintf(buf, sizeof(buf), "%s/%s.%s", pixmap_roots[pr], name, exts[e]);
|
|
if (access(buf, R_OK) == 0)
|
|
return strdup(buf);
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* Decode a PNG into straight (non-premultiplied) RGBA8, malloc'd. */
|
|
static unsigned char *overlay_load_png(const char *path, int *w, int *h) {
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f) return NULL;
|
|
png_structp png = png_create_read_struct(PNG_LIBPNG_VER_STRING, NULL, NULL,
|
|
NULL);
|
|
if (!png) {
|
|
fclose(f);
|
|
return NULL;
|
|
}
|
|
png_infop info = png_create_info_struct(png);
|
|
if (!info) {
|
|
png_destroy_read_struct(&png, NULL, NULL);
|
|
fclose(f);
|
|
return NULL;
|
|
}
|
|
if (setjmp(png_jmpbuf(png))) {
|
|
png_destroy_read_struct(&png, &info, NULL);
|
|
fclose(f);
|
|
return NULL;
|
|
}
|
|
png_init_io(png, f);
|
|
png_read_info(png, info);
|
|
int bw = (int)png_get_image_width(png, info);
|
|
int bh = (int)png_get_image_height(png, info);
|
|
png_byte ct = png_get_color_type(png, info);
|
|
png_byte bd = png_get_bit_depth(png, info);
|
|
if (ct == PNG_COLOR_TYPE_PALETTE)
|
|
png_set_palette_to_rgb(png);
|
|
if (ct == PNG_COLOR_TYPE_GRAY && bd < 8)
|
|
png_set_expand_gray_1_2_4_to_8(png);
|
|
if (png_get_valid(png, info, PNG_INFO_tRNS))
|
|
png_set_tRNS_to_alpha(png);
|
|
if (bd == 16)
|
|
png_set_strip_16(png);
|
|
if (ct == PNG_COLOR_TYPE_RGB || ct == PNG_COLOR_TYPE_GRAY)
|
|
png_set_filler(png, 0xff, PNG_FILLER_AFTER);
|
|
if (ct == PNG_COLOR_TYPE_GRAY || ct == PNG_COLOR_TYPE_GRAY_ALPHA)
|
|
png_set_gray_to_rgb(png);
|
|
png_read_update_info(png, info);
|
|
|
|
size_t rowbytes = png_get_rowbytes(png, info);
|
|
size_t size = (size_t)bh * rowbytes;
|
|
unsigned char *rgba = malloc(size);
|
|
if (!rgba) {
|
|
png_destroy_read_struct(&png, &info, NULL);
|
|
fclose(f);
|
|
return NULL;
|
|
}
|
|
png_bytep *rows = malloc(sizeof(png_bytep) * (size_t)bh);
|
|
if (!rows) {
|
|
free(rgba);
|
|
png_destroy_read_struct(&png, &info, NULL);
|
|
fclose(f);
|
|
return NULL;
|
|
}
|
|
for (int y = 0; y < bh; y++)
|
|
rows[y] = rgba + (size_t)y * rowbytes;
|
|
png_read_image(png, rows);
|
|
free(rows);
|
|
png_destroy_read_struct(&png, &info, NULL);
|
|
fclose(f);
|
|
*w = bw;
|
|
*h = bh;
|
|
return rgba;
|
|
}
|
|
|
|
/* Decode an SVG into straight RGBA8 at its intrinsic size, malloc'd. */
|
|
static unsigned char *overlay_load_svg(const char *path, int *w, int *h) {
|
|
GError *err = NULL;
|
|
RsvgHandle *handle = rsvg_handle_new_from_file(path, &err);
|
|
if (!handle || err) {
|
|
if (err) g_error_free(err);
|
|
return NULL;
|
|
}
|
|
double iw = 0, ih = 0;
|
|
if (!rsvg_handle_get_intrinsic_size_in_pixels(handle, &iw, &ih))
|
|
iw = ih = 48.0; /* dimension-less SVG: assume an icon-sized square */
|
|
if (iw <= 0 || ih <= 0) {
|
|
g_object_unref(handle);
|
|
return NULL;
|
|
}
|
|
int bw = (int)iw, bh = (int)ih;
|
|
cairo_surface_t *surf =
|
|
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, bw, bh);
|
|
if (cairo_surface_status(surf) != CAIRO_STATUS_SUCCESS) {
|
|
cairo_surface_destroy(surf);
|
|
g_object_unref(handle);
|
|
return NULL;
|
|
}
|
|
cairo_t *cr = cairo_create(surf);
|
|
RsvgRectangle viewport = { 0, 0, iw, ih };
|
|
if (!rsvg_handle_render_document(handle, cr, &viewport, &err)) {
|
|
if (err) g_error_free(err);
|
|
cairo_destroy(cr);
|
|
cairo_surface_destroy(surf);
|
|
g_object_unref(handle);
|
|
return NULL;
|
|
}
|
|
cairo_destroy(cr);
|
|
cairo_surface_flush(surf);
|
|
|
|
/* Cairo's ARGB32 is premultiplied, little-endian byte order B,G,R,A. */
|
|
int n = bw * bh;
|
|
unsigned char *rgba = malloc(sizeof(unsigned char) * 4 * (size_t)n);
|
|
if (rgba) {
|
|
unsigned char *src = cairo_image_surface_get_data(surf);
|
|
for (int i = 0; i < n; i++) {
|
|
unsigned char a = src[i * 4 + 3];
|
|
unsigned char r = src[i * 4 + 2];
|
|
unsigned char g = src[i * 4 + 1];
|
|
unsigned char b = src[i * 4 + 0];
|
|
if (a > 0) {
|
|
r = (unsigned char)MIN((r * 255 + a / 2) / a, 255);
|
|
g = (unsigned char)MIN((g * 255 + a / 2) / a, 255);
|
|
b = (unsigned char)MIN((b * 255 + a / 2) / a, 255);
|
|
}
|
|
rgba[i * 4 + 0] = r;
|
|
rgba[i * 4 + 1] = g;
|
|
rgba[i * 4 + 2] = b;
|
|
rgba[i * 4 + 3] = a;
|
|
}
|
|
}
|
|
cairo_surface_destroy(surf);
|
|
g_object_unref(handle);
|
|
*w = bw;
|
|
*h = bh;
|
|
return rgba;
|
|
}
|
|
|
|
/* Resolve and upload `app`'s icon as a GL_RGBA texture (host EGL context
|
|
must be current). Best-effort: on any failure the app keeps no icon and
|
|
the overlay draws the placeholder instead. */
|
|
/* Resolve and upload an overlay icon for `app_id` into the supplied outputs.
|
|
Shared by the per-tab and per-window loaders so each tab-group window can
|
|
show its own icon; sets *tex (nested GL path) or *wlr (standalone path),
|
|
leaving the unused one NULL. */
|
|
static void overlay_make_icon(const char *app_id,
|
|
GLuint *tex, int *ow, int *oh,
|
|
struct wlr_texture **wlr) {
|
|
*tex = 0; *ow = 0; *oh = 0; if (wlr) *wlr = NULL;
|
|
if (!app_id || !app_id[0])
|
|
return;
|
|
|
|
char *icon = desktop_icon_name(app_id);
|
|
char *path = overlay_find_icon_file(icon ? icon : app_id);
|
|
free(icon);
|
|
if (!path)
|
|
return;
|
|
|
|
int w = 0, h = 0;
|
|
unsigned char *rgba = NULL;
|
|
if (overlay_endswith(path, ".svg"))
|
|
rgba = overlay_load_svg(path, &w, &h);
|
|
else if (overlay_endswith(path, ".png"))
|
|
rgba = overlay_load_png(path, &w, &h);
|
|
free(path);
|
|
if (!rgba || w <= 0 || h <= 0) {
|
|
free(rgba);
|
|
return;
|
|
}
|
|
|
|
if (server.standalone) {
|
|
/* No host GL context: hand the icon to the wlroots renderer as a
|
|
premultiplied ARGB texture (B,G,R,A byte order). */
|
|
unsigned char *pre = malloc((size_t)w * h * 4);
|
|
if (pre) {
|
|
for (int i = 0; i < w * h; i++) {
|
|
unsigned char a = rgba[i * 4 + 3];
|
|
if (a == 0) {
|
|
pre[i * 4 + 0] = 0;
|
|
pre[i * 4 + 1] = 0;
|
|
pre[i * 4 + 2] = 0;
|
|
pre[i * 4 + 3] = 0;
|
|
} else {
|
|
pre[i * 4 + 0] = (unsigned char)(rgba[i * 4 + 2] * a / 255);
|
|
pre[i * 4 + 1] = (unsigned char)(rgba[i * 4 + 1] * a / 255);
|
|
pre[i * 4 + 2] = (unsigned char)(rgba[i * 4 + 0] * a / 255);
|
|
pre[i * 4 + 3] = a;
|
|
}
|
|
}
|
|
*wlr = wlr_texture_from_pixels(server.renderer,
|
|
DRM_FORMAT_ARGB8888, (uint32_t)w * 4, (uint32_t)w,
|
|
(uint32_t)h, pre);
|
|
free(pre);
|
|
}
|
|
free(rgba);
|
|
*ow = w; *oh = h;
|
|
return;
|
|
}
|
|
|
|
GLuint t;
|
|
glGenTextures(1, &t);
|
|
glBindTexture(GL_TEXTURE_2D, t);
|
|
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);
|
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0,
|
|
GL_RGBA, GL_UNSIGNED_BYTE, rgba);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
free(rgba);
|
|
*tex = t;
|
|
*ow = w; *oh = h;
|
|
}
|
|
|
|
static void overlay_icon_load_member(struct kick_tab_group_member *m) {
|
|
m->icon_resolved = true;
|
|
const char *id = member_app_id(m);
|
|
snprintf(m->icon_app_id, sizeof(m->icon_app_id), "%s", id ? id : "");
|
|
overlay_make_icon(m->icon_app_id, &m->icon_tex,
|
|
&m->icon_w, &m->icon_h, &m->icon_wlr);
|
|
if (m->icon_tex || m->icon_wlr)
|
|
kick_log(KICK_LOG_INFO, "[overlay] icon %s %dx%d\n",
|
|
m->icon_app_id, m->icon_w, m->icon_h);
|
|
}
|
|
|
|
static void draw_rect(int x, int y, int w, int h,
|
|
float r, float g, float b, float a)
|
|
{
|
|
if (w <= 0 || h <= 0) return;
|
|
GLfloat color[4] = { r, g, b, a };
|
|
draw_texture_rect(host.overlay_white_tex, 1, 1,
|
|
0, 0, 1, 1, x, y, w, h, host.width, host.height, color);
|
|
}
|
|
|
|
/* Draw `s` at (x, y) (window coords, y is the row top) tinted by `color`.
|
|
Non-ASCII characters are skipped with a narrow advance. */
|
|
static void draw_text(const char *s, int x, int y,
|
|
float r, float g, float b, float a)
|
|
{
|
|
if (!overlay_font_ok) return;
|
|
GLfloat color[4] = { r, g, b, a };
|
|
int pen = x;
|
|
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
|
int c = *p;
|
|
if (c < 32 || c > 126) {
|
|
pen += 4;
|
|
continue;
|
|
}
|
|
struct overlay_glyph *glyph = &host.overlay_glyphs[c - 32];
|
|
draw_texture_rect(host.overlay_atlas_tex, host.overlay_atlas_w,
|
|
host.overlay_atlas_h,
|
|
glyph->ax, glyph->ay, glyph->pw, glyph->ph,
|
|
pen, y + glyph->top, glyph->pw, glyph->ph,
|
|
host.width, host.height, color);
|
|
pen += glyph->adv;
|
|
}
|
|
}
|
|
|
|
static int overlay_text_width(const char *s) {
|
|
if (!overlay_font_ok) return 0;
|
|
int w = 0;
|
|
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
|
int c = *p;
|
|
if (c < 32 || c > 126) {
|
|
w += 4;
|
|
continue;
|
|
}
|
|
w += host.overlay_glyphs[c - 32].adv;
|
|
}
|
|
return w;
|
|
}
|
|
|
|
/* Copy `src` into `out`, truncated so that its on-screen width plus a
|
|
trailing "..." fits within `max_px` when the title does not already fit
|
|
whole. Uses the same advance rules as overlay_text_width(). A title that
|
|
fits within `max_px` is copied verbatim, so a row sized to a title's natural
|
|
width never grows a needless ellipsis (e.g. "foot" staying "foot" instead of
|
|
shrinking to "fo..."). Falls back to an empty string when not even one dot
|
|
fits. */
|
|
static void fit_text(const char *src, int max_px, char *out, int out_sz)
|
|
{
|
|
out[0] = '\0';
|
|
if (!src || out_sz <= 1) return;
|
|
|
|
if (overlay_text_width(src) <= max_px) {
|
|
size_t n = 0;
|
|
for (const unsigned char *p = (const unsigned char *)src; *p; p++) {
|
|
if (n + 1 >= (size_t)out_sz)
|
|
break;
|
|
out[n++] = (char)*p;
|
|
}
|
|
out[n] = '\0';
|
|
return;
|
|
}
|
|
|
|
const char *dots = "...";
|
|
int dot_w = 0;
|
|
for (const unsigned char *p = (const unsigned char *)dots; *p; p++) {
|
|
int c = *p;
|
|
if (c < 32 || c > 126) { dot_w += 4; continue; }
|
|
dot_w += host.overlay_glyphs[c - 32].adv;
|
|
}
|
|
if (max_px < dot_w) return;
|
|
|
|
kick_log(KICK_LOG_DEBUG, "[overlay] ellipsis: '%s' budget=%d\n",
|
|
src, max_px);
|
|
int w = 0;
|
|
size_t n = 0;
|
|
const unsigned char *p;
|
|
for (p = (const unsigned char *)src; *p; p++) {
|
|
int c = *p;
|
|
int adv = (c < 32 || c > 126) ? 4 : host.overlay_glyphs[c - 32].adv;
|
|
if (w + adv + dot_w > max_px)
|
|
break;
|
|
if (n + 1 >= (size_t)out_sz)
|
|
break;
|
|
out[n++] = (char)c;
|
|
w += adv;
|
|
}
|
|
if (*p) {
|
|
if (n + 4 < (size_t)out_sz) {
|
|
memcpy(out + n, dots, 3);
|
|
n += 3;
|
|
}
|
|
}
|
|
out[n] = '\0';
|
|
}
|
|
|
|
/* Row title for an overlay slot: the window the tab is actually showing --
|
|
on the active tab that is exactly the focused stack member (a stacked
|
|
tab-group member or the wrapped main surface); a background tab previews its
|
|
topmost mapped member, what switching over would show -- or the command
|
|
line for a still-black (pending) tab. Logs one INFO line whenever a
|
|
row's resolved title changes, so a stacked window swapping the title
|
|
underneath an unchanged tab is observable in logs and tests. */
|
|
static const char *app_row_title(struct kick_app *app) {
|
|
const char *t = NULL;
|
|
struct kick_tab_group_member *pick =
|
|
(app == server.active_app && app->focus_member)
|
|
? app->focus_member : top_mapped_member(app);
|
|
const char *st = pick ? member_title(pick) : NULL;
|
|
if (pick && st && st[0])
|
|
t = st;
|
|
else {
|
|
const char *mt = app_title(app);
|
|
if (mt && mt[0])
|
|
t = mt;
|
|
}
|
|
if (!t && app->cmd && app->cmd[0])
|
|
t = app->cmd;
|
|
const char *cur = t ? t : "";
|
|
if (strcmp(cur, app->row_title) != 0) {
|
|
kick_log(KICK_LOG_INFO, "[title] app=%d '%s'\n",
|
|
app_index(app), cur);
|
|
snprintf(app->row_title, sizeof(app->row_title), "%s", cur);
|
|
}
|
|
return t;
|
|
}
|
|
|
|
static bool overlay_member_icon_needs_load(struct kick_tab_group_member *m) {
|
|
if (!m->icon_resolved)
|
|
return true;
|
|
const char *id = member_app_id(m);
|
|
return id && strcmp(id, m->icon_app_id) != 0;
|
|
}
|
|
|
|
/* Display title for one tab-group window: its own title, else its app_id,
|
|
else a generic placeholder. Writes into buf when the title must be
|
|
synthesized (so the returned pointer always outlives the call). */
|
|
static const char *overlay_member_title(struct kick_tab_group_member *m,
|
|
char *buf, size_t sz) {
|
|
const char *t = member_title(m);
|
|
if (t && t[0])
|
|
return t;
|
|
const char *id = member_app_id(m);
|
|
if (id && id[0]) {
|
|
snprintf(buf, sz, "%s", id);
|
|
return buf;
|
|
}
|
|
return "window";
|
|
}
|
|
|
|
/* A row in the overlay (both the host-GL and the standalone wlr paths):
|
|
skip slots that are dead or mid-close; black pending tabs are rows too. */
|
|
static bool app_overlay_row(struct kick_app *app) {
|
|
return app_live(app) && !app->closing;
|
|
}
|
|
|
|
static void draw_tab_overlay(void) {
|
|
if (!host.overlay_visible || server.num_apps < 1) return;
|
|
|
|
int margin = 8, boxpad = 6;
|
|
int icon_sz = OVERLAY_ICON_SZ, icon_gap = 8, member_gap = 24;
|
|
int row_h = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
|
|
int row_gap = 4;
|
|
if (row_h < icon_sz + 4) row_h = icon_sz + 4;
|
|
int max_row_w = 0, n_rows = 0;
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
if (!app_overlay_row(app)) continue;
|
|
/* Keep the [title] log (focused window) current; per-window icons
|
|
are resolved lazily in the draw loop below. */
|
|
(void)app_row_title(app);
|
|
int n = 0, row_w = 0;
|
|
struct kick_tab_group_member *m;
|
|
wl_list_for_each(m, &app->group_members, link) {
|
|
if (!member_mapped(m)) continue;
|
|
char tb[64];
|
|
const char *t = overlay_member_title(m, tb, sizeof(tb));
|
|
row_w += icon_sz + icon_gap + overlay_text_width(t);
|
|
n++;
|
|
}
|
|
if (n > 1) row_w += (n - 1) * member_gap;
|
|
if (row_w > max_row_w) max_row_w = row_w;
|
|
if (n > 0) n_rows++;
|
|
}
|
|
if (n_rows == 0) return;
|
|
|
|
int avail_w = host.width - 2 * margin - 2 * boxpad;
|
|
if (max_row_w > avail_w) max_row_w = avail_w;
|
|
|
|
int box_w = max_row_w + 2 * boxpad;
|
|
int box_h = n_rows * row_h + (n_rows - 1) * row_gap + 2 * boxpad;
|
|
if (box_w > host.width - 2 * margin) box_w = host.width - 2 * margin;
|
|
if (box_h > host.height - 2 * margin) box_h = host.height - 2 * margin;
|
|
int box_x = (host.width - box_w) / 2;
|
|
int box_y = (host.height - box_h) / 2;
|
|
|
|
int bw = 1;
|
|
draw_rect(box_x, box_y, box_w, box_h, 0.45f, 0.45f, 0.48f, 1.0f);
|
|
draw_rect(box_x + bw, box_y + bw, box_w - 2 * bw, box_h - 2 * bw,
|
|
0.07f, 0.07f, 0.09f, 1.0f);
|
|
|
|
int row = 0;
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
if (!app_overlay_row(app)) continue;
|
|
(void)app_row_title(app);
|
|
int ry = box_y + boxpad + row * (row_h + row_gap);
|
|
bool active = app == server.active_app;
|
|
int row_inner = box_w - 2 * boxpad;
|
|
|
|
struct kick_tab_group_member *members[32];
|
|
char fitt[32][256];
|
|
int widths[32];
|
|
int n = 0;
|
|
struct kick_tab_group_member *m;
|
|
wl_list_for_each(m, &app->group_members, link) {
|
|
if (!member_mapped(m)) continue;
|
|
if (n >= 32) break;
|
|
if (overlay_member_icon_needs_load(m))
|
|
overlay_icon_load_member(m);
|
|
members[n++] = m;
|
|
}
|
|
int share = 0;
|
|
if (n > 0) {
|
|
int avail = row_inner - n * (icon_sz + icon_gap);
|
|
if (n > 1) avail -= (n - 1) * member_gap;
|
|
share = avail / n;
|
|
if (share < 0) share = 0;
|
|
}
|
|
int total_w = 0;
|
|
for (int k = 0; k < n; k++) {
|
|
char tb[64];
|
|
const char *dt = overlay_member_title(members[k], tb, sizeof(tb));
|
|
fit_text(dt, share, fitt[k], sizeof(fitt[k]));
|
|
widths[k] = overlay_text_width(fitt[k]);
|
|
total_w += icon_sz + icon_gap + widths[k];
|
|
}
|
|
if (n > 1) total_w += (n - 1) * member_gap;
|
|
|
|
int pen = box_x + boxpad + (row_inner - total_w) / 2;
|
|
int iy = ry + (row_h - icon_sz) / 2;
|
|
for (int k = 0; k < n; k++) {
|
|
struct kick_tab_group_member *m = members[k];
|
|
int tw = widths[k];
|
|
int mw = icon_sz + icon_gap + tw;
|
|
bool is_cursor = active && app->focus_member == m;
|
|
draw_rect(pen - 2, ry - 1, mw + 4, row_h,
|
|
0.22f, 0.22f, 0.25f, 1.0f);
|
|
if (is_cursor)
|
|
draw_rect(pen - 2, ry - 1, mw + 4, row_h,
|
|
0.24f, 0.36f, 0.60f, 0.9f);
|
|
int mx = pen, my = iy;
|
|
if (m->icon_tex) {
|
|
GLfloat tint[4] = { active ? 1.0f : 0.72f,
|
|
active ? 1.0f : 0.72f,
|
|
active ? 1.0f : 0.72f, 1.0f };
|
|
draw_texture_rect(m->icon_tex, m->icon_w, m->icon_h,
|
|
0, 0, m->icon_w, m->icon_h,
|
|
mx, my, icon_sz, icon_sz,
|
|
host.width, host.height, tint);
|
|
} else {
|
|
draw_rect(mx, my, icon_sz, icon_sz,
|
|
0.45f, 0.45f, 0.48f, 0.85f);
|
|
draw_rect(mx + 3, my + 3, icon_sz - 6, icon_sz - 6,
|
|
0.07f, 0.07f, 0.09f, 1.0f);
|
|
}
|
|
draw_text(fitt[k], mx + icon_sz + icon_gap,
|
|
ry + (row_h - host.overlay_font_h) / 2,
|
|
active ? 1.0f : 0.72f,
|
|
active ? 1.0f : 0.72f,
|
|
active ? 1.0f : 0.72f,
|
|
active ? 1.0f : 0.9f);
|
|
pen += mw + member_gap;
|
|
}
|
|
row++;
|
|
}
|
|
}
|
|
|
|
/* ---------- Standalone overlay rendering (wlr render pass) ----------
|
|
The standalone output paints via the wlroots GLES2 renderer, so the overlay
|
|
is drawn as a second pass on the same buffer directly with wlr textures.
|
|
Mirrors the layout computed by draw_tab_overlay() for the host-GL path. */
|
|
|
|
static struct wlr_render_color overlay_premul(float r, float g, float b, float a) {
|
|
return (struct wlr_render_color){ r * a, g * a, b * a, a };
|
|
}
|
|
|
|
static void overlay_pass_rect(struct wlr_render_pass *pass,
|
|
int x, int y, int w, int h,
|
|
float r, float g, float b, float a)
|
|
{
|
|
if (w <= 0 || h <= 0) return;
|
|
struct wlr_render_rect_options o = {
|
|
.box = { .x = x, .y = y, .width = w, .height = h },
|
|
.color = overlay_premul(r, g, b, a),
|
|
};
|
|
wlr_render_pass_add_rect(pass, &o);
|
|
}
|
|
|
|
static void overlay_pass_text(struct wlr_render_pass *pass, const char *s,
|
|
int x, int y, float alpha)
|
|
{
|
|
if (!overlay_font_ok || !server.overlay_atlas_wlr) return;
|
|
int pen = x;
|
|
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
|
int c = *p;
|
|
if (c < 32 || c > 126) {
|
|
pen += 4;
|
|
continue;
|
|
}
|
|
struct overlay_glyph *glyph = &host.overlay_glyphs[c - 32];
|
|
if (glyph->pw > 0 && glyph->ph > 0) {
|
|
struct wlr_render_texture_options o = {
|
|
.texture = server.overlay_atlas_wlr,
|
|
.src_box = { .x = glyph->ax, .y = glyph->ay,
|
|
.width = glyph->pw, .height = glyph->ph },
|
|
.dst_box = { .x = pen, .y = y + glyph->top,
|
|
.width = glyph->pw, .height = glyph->ph },
|
|
.alpha = &alpha,
|
|
.filter_mode = WLR_SCALE_FILTER_BILINEAR,
|
|
.blend_mode = WLR_RENDER_BLEND_MODE_PREMULTIPLIED,
|
|
};
|
|
wlr_render_pass_add_texture(pass, &o);
|
|
}
|
|
pen += glyph->adv;
|
|
}
|
|
}
|
|
|
|
static void overlay_pass_icon_tex(struct wlr_render_pass *pass,
|
|
struct wlr_texture *tex,
|
|
int ix, int iy, int size, float alpha)
|
|
{
|
|
if (!tex) return;
|
|
struct wlr_render_texture_options o = {
|
|
.texture = tex,
|
|
.dst_box = { .x = ix, .y = iy, .width = size, .height = size },
|
|
.alpha = &alpha,
|
|
.filter_mode = WLR_SCALE_FILTER_BILINEAR,
|
|
.blend_mode = WLR_RENDER_BLEND_MODE_PREMULTIPLIED,
|
|
};
|
|
wlr_render_pass_add_texture(pass, &o);
|
|
}
|
|
|
|
static void draw_tab_overlay_pass(struct wlr_render_pass *pass) {
|
|
if (!host.overlay_visible || server.num_apps < 1) return;
|
|
|
|
int margin = 8, boxpad = 6;
|
|
int icon_sz = OVERLAY_ICON_SZ, icon_gap = 8, member_gap = 24;
|
|
int row_h = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
|
|
int row_gap = 4;
|
|
if (row_h < icon_sz + 4) row_h = icon_sz + 4;
|
|
int max_row_w = 0, n_rows = 0;
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
if (!app_overlay_row(app)) continue;
|
|
(void)app_row_title(app);
|
|
int n = 0, row_w = 0;
|
|
struct kick_tab_group_member *m;
|
|
wl_list_for_each(m, &app->group_members, link) {
|
|
if (!member_mapped(m)) continue;
|
|
char tb[64];
|
|
const char *t = overlay_member_title(m, tb, sizeof(tb));
|
|
row_w += icon_sz + icon_gap + overlay_text_width(t);
|
|
n++;
|
|
}
|
|
if (n > 1) row_w += (n - 1) * member_gap;
|
|
if (row_w > max_row_w) max_row_w = row_w;
|
|
if (n > 0) n_rows++;
|
|
}
|
|
if (n_rows == 0) return;
|
|
|
|
int avail_w = host.width - 2 * margin - 2 * boxpad;
|
|
if (max_row_w > avail_w) max_row_w = avail_w;
|
|
|
|
int box_w = max_row_w + 2 * boxpad;
|
|
int box_h = n_rows * row_h + (n_rows - 1) * row_gap + 2 * boxpad;
|
|
if (box_w > host.width - 2 * margin) box_w = host.width - 2 * margin;
|
|
if (box_h > host.height - 2 * margin) box_h = host.height - 2 * margin;
|
|
int box_x = (host.width - box_w) / 2;
|
|
int box_y = (host.height - box_h) / 2;
|
|
|
|
int bw = 1;
|
|
overlay_pass_rect(pass, box_x, box_y, box_w, box_h, 0.45f, 0.45f, 0.48f, 1.0f);
|
|
overlay_pass_rect(pass, box_x + bw, box_y + bw, box_w - 2 * bw,
|
|
box_h - 2 * bw, 0.07f, 0.07f, 0.09f, 1.0f);
|
|
|
|
int row = 0;
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
if (!app_overlay_row(app)) continue;
|
|
(void)app_row_title(app);
|
|
int ry = box_y + boxpad + row * (row_h + row_gap);
|
|
bool active = app == server.active_app;
|
|
int row_inner = box_w - 2 * boxpad;
|
|
|
|
struct kick_tab_group_member *members[32];
|
|
char fitt[32][256];
|
|
int widths[32];
|
|
int n = 0;
|
|
struct kick_tab_group_member *m;
|
|
wl_list_for_each(m, &app->group_members, link) {
|
|
if (!member_mapped(m)) continue;
|
|
if (n >= 32) break;
|
|
if (overlay_member_icon_needs_load(m))
|
|
overlay_icon_load_member(m);
|
|
members[n++] = m;
|
|
}
|
|
int share = 0;
|
|
if (n > 0) {
|
|
int avail = row_inner - n * (icon_sz + icon_gap);
|
|
if (n > 1) avail -= (n - 1) * member_gap;
|
|
share = avail / n;
|
|
if (share < 0) share = 0;
|
|
}
|
|
int total_w = 0;
|
|
for (int k = 0; k < n; k++) {
|
|
char tb[64];
|
|
const char *dt = overlay_member_title(members[k], tb, sizeof(tb));
|
|
fit_text(dt, share, fitt[k], sizeof(fitt[k]));
|
|
widths[k] = overlay_text_width(fitt[k]);
|
|
total_w += icon_sz + icon_gap + widths[k];
|
|
}
|
|
if (n > 1) total_w += (n - 1) * member_gap;
|
|
|
|
int pen = box_x + boxpad + (row_inner - total_w) / 2;
|
|
int iy = ry + (row_h - icon_sz) / 2;
|
|
for (int k = 0; k < n; k++) {
|
|
struct kick_tab_group_member *m = members[k];
|
|
int tw = widths[k];
|
|
int mw = icon_sz + icon_gap + tw;
|
|
bool is_cursor = active && app->focus_member == m;
|
|
overlay_pass_rect(pass, pen - 2, ry - 1, mw + 4, row_h,
|
|
0.22f, 0.22f, 0.25f, 1.0f);
|
|
if (is_cursor)
|
|
overlay_pass_rect(pass, pen - 2, ry - 1, mw + 4, row_h,
|
|
0.24f, 0.36f, 0.60f, 0.9f);
|
|
int mx = pen, my = iy;
|
|
if (m->icon_wlr) {
|
|
overlay_pass_icon_tex(pass, m->icon_wlr, mx, my, icon_sz,
|
|
active ? 1.0f : 0.72f);
|
|
} else {
|
|
overlay_pass_rect(pass, mx, my, icon_sz, icon_sz,
|
|
0.45f, 0.45f, 0.48f, 0.85f);
|
|
overlay_pass_rect(pass, mx + 3, my + 3, icon_sz - 6, icon_sz - 6,
|
|
0.07f, 0.07f, 0.09f, 1.0f);
|
|
}
|
|
overlay_pass_text(pass, fitt[k], mx + icon_sz + icon_gap,
|
|
ry + (row_h - host.overlay_font_h) / 2,
|
|
active ? 1.0f : 0.648f);
|
|
pen += mw + member_gap;
|
|
}
|
|
row++;
|
|
}
|
|
}
|
|
|
|
|
|
/* ===================== Server-side wlroots setup ===================== */
|
|
|
|
static void swapchain_resize(int width, int height) {
|
|
if (width < 50) width = 50;
|
|
if (height < 50) height = 50;
|
|
if (server.swapchain && server.swapchain->width == width
|
|
&& server.swapchain->height == height)
|
|
return;
|
|
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, "kick: 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;
|
|
|
|
/* The new size is applied to the output lazily at render time (see
|
|
apply_pending_output_resize); here only the scene geometry and the
|
|
clients are told, so configure-time stays free of modesets and
|
|
swapchain reallocation. */
|
|
|
|
if (server.bg_rect)
|
|
wlr_scene_rect_set_size(server.bg_rect, width, height);
|
|
|
|
/* Keep every mapped client at the current maximized size so switching
|
|
tabs is instant and no tab is left small after a host resize. While
|
|
fullscreen the fullscreen configure already sizes clients to the
|
|
output, so skip the maximize request to avoid state churn. */
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
if (!app_has_window(app)) continue;
|
|
if (!server.clients_fullscreen)
|
|
app_set_maximized(app, true);
|
|
app_set_size(app, width, height);
|
|
}
|
|
|
|
kick_request_redraw();
|
|
}
|
|
|
|
/* A host configure may resize the canvas; apply that size to the output
|
|
lazily, at render time, instead of modesetting and reallocating the
|
|
swapchain on every configure. Folding the custom mode into the pending
|
|
state makes wlr_scene build the frame at the new resolution and commit
|
|
mode+buffer in one atomic operation, so a drag costs one GBM alloc and one
|
|
commit per resized frame (no empty-buffer modeset churn in between).
|
|
Standalone outputs never change size here, so this is a no-op for them. */
|
|
static void apply_pending_output_resize(struct wlr_output_state *state) {
|
|
if (!server.output) return;
|
|
int w = host.width, h = host.height;
|
|
if (server.output->width == w && server.output->height == h) return;
|
|
swapchain_resize(w, h);
|
|
wlr_output_state_set_custom_mode(state, w, h, 0);
|
|
}
|
|
|
|
static void handle_output_frame(struct wl_listener *listener, void *data) {
|
|
(void)listener; (void)data;
|
|
frame_pending = true;
|
|
}
|
|
|
|
/* How many standalone outputs have been seen since the last (re)connect.
|
|
DRM destroys and re-creates the output when the session loses/regains its
|
|
VT, so this is reset to keep accepting the re-added connector. */
|
|
static int standalone_output_count;
|
|
|
|
/* DRM destroys the output when the session (VT) is lost. Clear our
|
|
references so nothing touches freed memory, detach the frame listener
|
|
(wlr_output_finish asserts it's empty), and let the connector re-added on
|
|
resume be accepted again. */
|
|
static void handle_output_destroy(struct wl_listener *listener, void *data) {
|
|
(void)listener;
|
|
(void)data;
|
|
wl_list_remove(&server.output_destroy.link);
|
|
wl_list_remove(&server.frame.link);
|
|
server.output = NULL;
|
|
server.scene_output = NULL; /* wlroots frees it via its output addon */
|
|
if (server.swapchain) {
|
|
wlr_swapchain_destroy(server.swapchain);
|
|
server.swapchain = NULL;
|
|
}
|
|
standalone_output_count = 0;
|
|
frame_pending = false;
|
|
needs_redraw = false;
|
|
}
|
|
|
|
static void handle_new_output(struct wl_listener *listener, void *data) {
|
|
(void)listener;
|
|
struct wlr_output *output = data;
|
|
|
|
/* Standalone (multi-monitor allowed at the wlroots layer): drive only
|
|
the first output with the kick canvas, like the headless nested
|
|
single output. Later outputs are left disabled. */
|
|
if (server.standalone && standalone_output_count++ > 0) {
|
|
kick_log(KICK_LOG_INFO,
|
|
"standalone: extra output ignored (first output only)\n");
|
|
return;
|
|
}
|
|
|
|
if (!wlr_output_init_render(output, server.allocator, server.renderer)) {
|
|
fprintf(stderr, "kick: output init_render failed\n");
|
|
return;
|
|
}
|
|
wlr_output_create_global(output, server.wl_display);
|
|
|
|
server.output = output;
|
|
server.scene_output = wlr_scene_output_create(server.scene, output);
|
|
wlr_scene_output_set_position(server.scene_output, 0, 0);
|
|
|
|
/* Always render the pointer into our own frame buffer (software cursor)
|
|
instead of a hardware cursor plane: the headless backend's set_cursor
|
|
hook makes wlroots believe it has a plane (so the scene software pass
|
|
is skipped) while actually drawing nothing, and a hardware plane would
|
|
float the cursor outside the buffer we composite/dump. */
|
|
if (server.standalone)
|
|
wlr_output_lock_software_cursors(output, true);
|
|
|
|
/* Give the standalone cursor a place to live. Idempotent across VT
|
|
round-trips (wlr_output_layout_add reuses the existing entry), and the
|
|
cursor attaches its output cursor via the layout's add signal. The
|
|
nested (host-window) output is added too, so xdg-output's
|
|
get_xdg_output finds the output in the layout instead of asserting. */
|
|
if (server.output_layout)
|
|
wlr_output_layout_add(server.output_layout, 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);
|
|
server.output_destroy.notify = handle_output_destroy;
|
|
wl_signal_add(&output->events.destroy, &server.output_destroy);
|
|
|
|
/* Enable the output up front so buffer commits succeed even before the
|
|
host window has been configured. A native output drives at its
|
|
preferred mode; everything else keeps the fixed headless size. */
|
|
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);
|
|
struct wlr_output_mode *mode = NULL;
|
|
if (server.standalone && !server.standalone_headless) {
|
|
if (mode_req_w > 0) {
|
|
/* --screen-mode WxH[@Hz]: pick an exact-resolution mode, highest
|
|
refresh when none was requested. A request the output can't
|
|
satisfy is a startup error: print every reported mode and exit
|
|
(before any client launches or the socket is advertised). */
|
|
struct wlr_output_mode *m;
|
|
int best_refresh = 0;
|
|
wl_list_for_each(m, &output->modes, link) {
|
|
if (m->width != mode_req_w || m->height != mode_req_h)
|
|
continue;
|
|
if (mode_req_refresh > 0) {
|
|
if (m->refresh == mode_req_refresh) {
|
|
mode = m;
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
if (m->refresh > best_refresh) {
|
|
best_refresh = m->refresh;
|
|
mode = m;
|
|
}
|
|
}
|
|
if (!mode) {
|
|
fprintf(stderr, "kick: no %dx%d",
|
|
mode_req_w, mode_req_h);
|
|
if (mode_req_refresh > 0)
|
|
fprintf(stderr, "@%u.%03u", mode_req_refresh / 1000,
|
|
mode_req_refresh % 1000);
|
|
if (wl_list_empty(&output->modes))
|
|
fprintf(stderr, " mode; output reports no modes\n");
|
|
else
|
|
fprintf(stderr, " mode; available:\n");
|
|
wl_list_for_each(m, &output->modes, link) {
|
|
fprintf(stderr, " %dx%d@%u.%03u%s%s\n",
|
|
m->width, m->height, m->refresh / 1000,
|
|
m->refresh % 1000,
|
|
m->preferred ? " (preferred)" : "",
|
|
(m->width == mode_req_w && m->height == mode_req_h)
|
|
? " (requested size)" : "");
|
|
}
|
|
exit(1);
|
|
}
|
|
} else {
|
|
mode = wlr_output_preferred_mode(output);
|
|
}
|
|
}
|
|
if (mode) {
|
|
w = mode->width;
|
|
h = mode->height;
|
|
wlr_output_state_set_mode(&state, mode);
|
|
} else {
|
|
wlr_output_state_set_custom_mode(&state, w, h, 0);
|
|
}
|
|
wlr_output_commit_state(output, &state);
|
|
wlr_output_state_finish(&state);
|
|
|
|
/* Overlay geometry and the swapchain are sized to the visible canvas;
|
|
in standalone there is no host window, so use the output size itself. */
|
|
if (server.standalone) {
|
|
host.width = w;
|
|
host.height = h;
|
|
}
|
|
swapchain_resize(w, h);
|
|
|
|
/* 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.)
|
|
On a re-connected output (VT round-trip) the node already exists; just
|
|
resize it. */
|
|
if (server.bg_rect) {
|
|
wlr_scene_rect_set_size(server.bg_rect, w, h);
|
|
} else {
|
|
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);
|
|
}
|
|
|
|
/* Standalone-only damage sentinel for the overlay; see
|
|
standalone_overlay_damage(). A fully transparent rect, sized to the
|
|
output and left enabled (it renders nothing). It sits at the bottom of
|
|
the z-order and is transiently raised inside standalone_overlay_damage()
|
|
to re-populate its visible region and force whole-output damage. */
|
|
if (server.overlay_damage_rect) {
|
|
wlr_scene_rect_set_size(server.overlay_damage_rect, w, h);
|
|
} else if (server.standalone) {
|
|
server.overlay_damage_rect = wlr_scene_rect_create(
|
|
&server.scene->tree, w, h, (float[]){ 0.0f, 0.0f, 0.0f, 0.0f });
|
|
}
|
|
}
|
|
|
|
/* Nearest live slot, scanning right first (prefer_right) or left first.
|
|
The scan stops at the array ends -- it never wraps -- so the reflow after
|
|
a slot is removed matches the old first/last-tab behavior. NULL when
|
|
`app` is the only live slot. */
|
|
static struct kick_app *pick_successor(struct kick_app *app,
|
|
int prefer_right) {
|
|
if (!app) return NULL;
|
|
int died = app_index(app);
|
|
if (prefer_right) {
|
|
for (int i = died + 1; i < server.num_apps; i++)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
|
return server.apps[i];
|
|
for (int i = died - 1; i >= 0; i--)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
|
return server.apps[i];
|
|
} else {
|
|
for (int i = died - 1; i >= 0; i--)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
|
return server.apps[i];
|
|
for (int i = died + 1; i < server.num_apps; i++)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
|
return server.apps[i];
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* Drop slot `idx` outright: free the tab's allocations, sever any other
|
|
slot's parent_app reference to it, and compact server.apps[] so a closed
|
|
tab leaves no hole that a later client could stumble into. The caller
|
|
must already have handed the active crown elsewhere; remove_app only
|
|
defends active_app against a slot that was somehow still on it. */
|
|
static void remove_app(int idx) {
|
|
if (idx < 0 || idx >= server.num_apps) return;
|
|
struct kick_app *dead = server.apps[idx];
|
|
if (server.active_app == dead)
|
|
server.active_app = NULL;
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (server.apps[i]->parent_app == dead)
|
|
server.apps[i]->parent_app = NULL;
|
|
if (dead->close_timer) {
|
|
wl_event_source_remove(dead->close_timer);
|
|
dead->close_timer = NULL;
|
|
}
|
|
if (host.egl_ok && dead->icon_tex) {
|
|
glDeleteTextures(1, &dead->icon_tex);
|
|
dead->icon_tex = 0;
|
|
}
|
|
if (dead->icon_wlr) {
|
|
wlr_texture_destroy(dead->icon_wlr);
|
|
dead->icon_wlr = NULL;
|
|
}
|
|
/* The window stack: the wrapped main surface plus stacked tab-group members
|
|
(--tab-groups on), oldest first, newest arrivals at the tail.
|
|
Members with listeners get them removed here; the listener-free
|
|
main wrapper just drops out of the list. */
|
|
while (!wl_list_empty(&dead->group_members)) {
|
|
struct kick_tab_group_member *sub =
|
|
wl_container_of(dead->group_members.next, sub, link);
|
|
if (sub->is_main) {
|
|
/* The wrapped main surface carries no listeners; its scene
|
|
nodes died with the toplevel above. */
|
|
wl_list_remove(&sub->link);
|
|
free(sub);
|
|
continue;
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
if (sub->is_x11) {
|
|
wl_list_remove(&sub->destroy.link);
|
|
wl_list_remove(&sub->xs_associate.link);
|
|
if (sub->xs_surface_linked) {
|
|
wl_list_remove(&sub->map.link);
|
|
wl_list_remove(&sub->unmap.link);
|
|
wl_list_remove(&sub->commit.link);
|
|
}
|
|
} else
|
|
#endif
|
|
{
|
|
wl_list_remove(&sub->destroy.link);
|
|
wl_list_remove(&sub->map.link);
|
|
wl_list_remove(&sub->unmap.link);
|
|
wl_list_remove(&sub->commit.link);
|
|
}
|
|
wl_list_remove(&sub->link);
|
|
free(sub);
|
|
}
|
|
free(dead->cmd);
|
|
free(dead);
|
|
memmove(&server.apps[idx], &server.apps[idx + 1],
|
|
(size_t)(server.num_apps - idx - 1) * sizeof(*server.apps));
|
|
server.num_apps--;
|
|
}
|
|
|
|
/* Shared tail of a tab's window-destroy handling (xdg and X11 alike); see
|
|
the definition below handle_toplevel_destroy. */
|
|
static void finish_app_destroy(struct kick_app *app);
|
|
|
|
static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, destroy);
|
|
app->toplevel = NULL;
|
|
/* The main surface's scene node dies with the xdg surface (created via
|
|
wlr_scene_xdg_surface_create); drop our pointers to both levels. */
|
|
app->tree = NULL;
|
|
app->stack = NULL;
|
|
|
|
/* The overlay's row for this tab disappears the moment the toplevel does,
|
|
shrinking the box mid-menu. The overlay is drawn into the scene-output
|
|
buffer outside the scene graph, so its damage trackers never learn about
|
|
the old pixels; without whole-output damage the vacated ring of the
|
|
larger box stays baked into the reused swapchain buffer. */
|
|
if (host.overlay_visible && server.standalone)
|
|
standalone_overlay_damage();
|
|
if (app->icon_tex) {
|
|
glDeleteTextures(1, &app->icon_tex);
|
|
app->icon_tex = 0;
|
|
}
|
|
|
|
/* 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);
|
|
finish_app_destroy(app);
|
|
}
|
|
|
|
/* Shared tail of a tab's window-destroy handling (xdg and X11 alike): drop
|
|
the Alt+Delete escalation state, hand the active crown to a neighbor (or
|
|
tear the session down when the last tab goes), and remove the slot. */
|
|
static void finish_app_destroy(struct kick_app *app) {
|
|
app->closing = false;
|
|
if (app->close_timer) {
|
|
wl_event_source_remove(app->close_timer);
|
|
app->close_timer = NULL;
|
|
}
|
|
|
|
if (server.active_app == app) {
|
|
server.active_app = NULL;
|
|
/* Prefer the neighbor to the left (index died-1); if this is the
|
|
first tab, fall through to the right-side neighbor so the tab
|
|
that was second becomes the new leftmost. */
|
|
struct kick_app *succ = pick_successor(app, -1);
|
|
if (succ) {
|
|
set_active_app(succ);
|
|
} else {
|
|
/* last tab closed: close the host window (nested only) and exit */
|
|
host_teardown_window();
|
|
shutdown_cleanup();
|
|
exit(0);
|
|
}
|
|
}
|
|
/* The slot itself is removed outright: closed tabs leave no hole. */
|
|
remove_app(app_index(app));
|
|
}
|
|
|
|
/* Grace period after the Alt+Delete SIGTERM before SIGKILL escalation. */
|
|
#define KICK_CLOSE_ESCALATE_MS 150
|
|
|
|
/* Escalate an Alt+Delete close to SIGKILL if the client ignored the SIGTERM
|
|
and its toplevel is still up. Live slots are removed at toplevel destroy
|
|
(which also cancels this timer), so for a mapped tab the callback never
|
|
fires against a reaped or reused pid. For a black tab there is no
|
|
toplevel whose destroy would reap the slot, so its removal happens here,
|
|
right after the SIGKILL. */
|
|
static int close_tab_escalate(void *data) {
|
|
struct kick_app *app = data;
|
|
if (app->closing && app_killable(app))
|
|
kill_app(app, SIGKILL);
|
|
if (app->close_timer) {
|
|
wl_event_source_remove(app->close_timer);
|
|
app->close_timer = NULL;
|
|
}
|
|
if (app->closing && !app_has_window(app))
|
|
remove_app(app_index(app));
|
|
return 0;
|
|
}
|
|
|
|
/* Tab close (Alt+Delete on the active tab, or an X11 window's close
|
|
request). The tab vanishes immediately: the active crown moves to the
|
|
right neighbor (or the left neighbor if the closed tab was the rightmost)
|
|
in this same call. The client's whole process group gets SIGTERM, then
|
|
SIGKILL after a short grace if it ignores the TERM (see
|
|
close_tab_escalate); X11 windows also get WM_DELETE_WINDOW first. A
|
|
closing black tab (still pending) drops out of the overlay and cycle right
|
|
away and is removed by the escalation; a closing last tab tears the
|
|
session down here, since nothing else will. */
|
|
static void close_tab(struct kick_app *app) {
|
|
if (!app || !app_killable(app)) return;
|
|
pid_t g = app_signal_group(app);
|
|
pid_t shown = (g > 0 && g != getpgid(0)) ? g : app->client_pid;
|
|
|
|
app->closing = true;
|
|
int died = app_index(app);
|
|
|
|
/* A black tab never fires a toplevel destroy, so drop it from the
|
|
overlay/cycle immediately; only the escalation timer and the
|
|
success-removal paths touch it afterwards. */
|
|
if (!app_has_window(app))
|
|
app->pending_claim = false;
|
|
|
|
struct kick_app *succ = pick_successor(app, +1);
|
|
if (succ) {
|
|
set_active_app(succ);
|
|
} else {
|
|
/* Last live tab: tear the session down now. */
|
|
kick_log(KICK_LOG_INFO, "kick: closing tab %d (pid %d)\n",
|
|
died, (int)shown);
|
|
host_teardown_window();
|
|
shutdown_cleanup();
|
|
exit(0);
|
|
}
|
|
|
|
/* X11 windows get WM_DELETE_WINDOW first; the SIGTERM below (and the
|
|
SIGKILL escalation) remains the universal fallback for both kinds. */
|
|
app_graceful_close(app);
|
|
kill_app(app, SIGTERM);
|
|
kick_log(KICK_LOG_INFO, "kick: closing tab %d (pid %d)\n",
|
|
died, (int)shown);
|
|
app->close_timer = wl_event_loop_add_timer(server.loop, close_tab_escalate,
|
|
app);
|
|
if (app->close_timer)
|
|
wl_event_source_timer_update(app->close_timer, KICK_CLOSE_ESCALATE_MS);
|
|
}
|
|
|
|
/* Alt+Delete on the active tab closes the active WINDOW, not the whole tab:
|
|
the focused stack member (a stacked tab-group window) is asked to close on
|
|
its own and the tab stays up; only when the tab holds a single window (its
|
|
wrapped main surface) does Delete take the whole tab down. The member's
|
|
own destroy path (member_destroy_common) unstacks it and refocuses the tab,
|
|
just as if the client had closed it directly. */
|
|
static void close_active_window(void) {
|
|
struct kick_app *app = server.active_app;
|
|
if (!app) return;
|
|
struct kick_tab_group_member *m =
|
|
(server.active_app == app) ? app->focus_member : NULL;
|
|
if (m && !m->is_main) {
|
|
/* A stacked tab-group window: ask it to close; its destroy handler
|
|
unstacks it and refocuses the tab. Either protocol path works. */
|
|
if (m->toplevel) {
|
|
wlr_xdg_toplevel_send_close(m->toplevel);
|
|
return;
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
if (m->is_x11 && m->xsurface) {
|
|
wlr_xwayland_surface_close(m->xsurface);
|
|
return;
|
|
}
|
|
#endif
|
|
/* Member has no closable surface: fall through to the whole tab. */
|
|
}
|
|
close_tab(app);
|
|
}
|
|
|
|
static void handle_request_maximize(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, request_maximize);
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
|
if (toplevel->base->initialized)
|
|
wlr_xdg_toplevel_set_maximized(toplevel, true);
|
|
}
|
|
|
|
static void handle_request_fullscreen(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, request_fullscreen);
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
|
if (!toplevel->base->initialized) return;
|
|
/* wlroots 0.19 emits request_fullscreen with NULL data; the direction
|
|
lives in toplevel->requested.fullscreen. Honor the client's request
|
|
and mirror it onto the host window: a fullscreen client fills the
|
|
host window, and an un-fullscreen client puts it back. */
|
|
bool fs = toplevel->requested.fullscreen;
|
|
wlr_xdg_toplevel_set_fullscreen(toplevel, fs);
|
|
kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", fs);
|
|
if (host.frame) {
|
|
if (fs)
|
|
libdecor_frame_set_fullscreen(host.frame, NULL);
|
|
else
|
|
libdecor_frame_unset_fullscreen(host.frame);
|
|
}
|
|
}
|
|
|
|
/* Propagate the host window's fullscreen state onto every client. Called
|
|
from host_xdg_toplevel_configure when that state actually changes. */
|
|
static void clients_set_fullscreen(bool fs) {
|
|
server.clients_fullscreen = fs;
|
|
kick_log(KICK_LOG_INFO, "clients fullscreen=%d\n", fs);
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
if (!app_initialized(app)) continue;
|
|
app_set_fullscreen(app, fs);
|
|
if (!fs) {
|
|
app_set_maximized(app, true);
|
|
app_set_size(app, server.output->width, server.output->height);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/* Place a scene tree so the client's content sits at 0,0. Shared by tabs
|
|
and stacked tab-group members. */
|
|
static void position_tree(struct wlr_xdg_toplevel *toplevel,
|
|
struct wlr_scene_tree *tree) {
|
|
if (!tree) return;
|
|
/* 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. */
|
|
struct wlr_box geom = toplevel->base->geometry;
|
|
int y = -geom.y;
|
|
if (geom.y < 0)
|
|
y = 0;
|
|
wlr_scene_node_set_position(&tree->node, -geom.x, y);
|
|
}
|
|
|
|
static void position_toplevel(struct kick_app *app) {
|
|
if (!app || !app->tree || !app->toplevel) return;
|
|
position_tree(app->toplevel, app->tree);
|
|
}
|
|
|
|
static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_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);
|
|
if (server.clients_fullscreen)
|
|
wlr_xdg_toplevel_set_fullscreen(toplevel, true);
|
|
}
|
|
/* 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);
|
|
|
|
/* [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_visible(app);
|
|
if (kick_log_level() >= KICK_LOG_DEBUG) {
|
|
kick_log(KICK_LOG_DEBUG, "[diag] commit app=%d geom=%dx%d+%d+%d "
|
|
"buf=%dx%d dma=%d%s node_enabled=%d active=%d\n",
|
|
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_app == app);
|
|
if (is_dma) {
|
|
kick_log(KICK_LOG_DEBUG, "[diag] dmabuf planes=%d "
|
|
"format=0x%x mod=0x%llx\n",
|
|
dma.n_planes, dma.format,
|
|
(unsigned long long)dma.modifier);
|
|
}
|
|
}
|
|
if (!is_dma) {
|
|
/* shm buffer: flag for KICK_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". */
|
|
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;
|
|
if (kick_log_level() >= KICK_LOG_DEBUG) {
|
|
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++;
|
|
}
|
|
}
|
|
kick_log(KICK_LOG_DEBUG, "[diag] shm fmt=0x%x "
|
|
"nonblack=%lu/%lu mean=%lu\n", fmt, nonblack,
|
|
(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);
|
|
kick_log(KICK_LOG_DEBUG, "[diag] client buffer "
|
|
"-> %s\n", cpath);
|
|
}
|
|
}
|
|
wlr_buffer_end_data_ptr_access(cbuf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Insert `app` in the slot order immediately after `anchor`, shifting the
|
|
slots after it right; append at the far right if anchor is NULL or no
|
|
longer present. */
|
|
static void insert_app_after(struct kick_app *app, struct kick_app *anchor) {
|
|
int at = server.num_apps;
|
|
if (anchor)
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (server.apps[i] == anchor) {
|
|
at = i + 1;
|
|
break;
|
|
}
|
|
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++;
|
|
}
|
|
|
|
/* A new toplevel is a tab-group member when its client was forked -- directly or
|
|
transitively -- from an already-connected tab's client: the shell running
|
|
inside a terminal tab forks gnome-calculator, so the calculator's pid chain
|
|
runs back through the shell to the terminal client. Exact pid equality (a
|
|
client opening a second toplevel) is the depth-0 case. Walk /proc/<pid>/stat
|
|
ppid links, capped, to find the owning tab; NULL if the client is not
|
|
descended from any tab. */
|
|
static struct kick_app *tab_group_owner(pid_t cpid) {
|
|
if (cpid <= 1)
|
|
return NULL;
|
|
pid_t p = cpid;
|
|
for (int depth = 0; depth < 8; depth++) {
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (app_has_window(server.apps[i]) &&
|
|
server.apps[i]->client_pid == p)
|
|
return server.apps[i];
|
|
char path[64];
|
|
snprintf(path, sizeof(path), "/proc/%d/stat", (int)p);
|
|
int fd = open(path, O_RDONLY | O_CLOEXEC);
|
|
if (fd < 0)
|
|
return NULL;
|
|
char buf[4096];
|
|
ssize_t n = read(fd, buf, sizeof(buf) - 1);
|
|
close(fd);
|
|
if (n <= 0)
|
|
return NULL;
|
|
buf[n] = '\0';
|
|
char *last = strrchr(buf, ')');
|
|
if (!last)
|
|
return NULL;
|
|
char state;
|
|
pid_t ppid = -1;
|
|
if (sscanf(last + 1, " %c %d", &state, &ppid) != 2 || ppid <= 1)
|
|
return NULL;
|
|
p = ppid;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* ===================== Stacked tab-group members (--tab-groups on) ===== */
|
|
|
|
/* Whether a stack member currently has a live, mapped surface: stacked
|
|
tab-group members track it through their map/unmap listeners; the wrapped
|
|
main surface exists exactly as long as its surface does. */
|
|
static bool member_mapped(struct kick_tab_group_member *m) {
|
|
return m->is_main ? app_has_window(m->owner) : m->mapped;
|
|
}
|
|
|
|
/* The topmost mapped member of the tab's window stack -- newest stacked
|
|
tab-group member if any, else the wrapped main surface, else NULL. */
|
|
static struct kick_tab_group_member *top_mapped_member(struct kick_app *app) {
|
|
struct kick_tab_group_member *sub;
|
|
wl_list_for_each_reverse(sub, &app->group_members, link)
|
|
if (member_mapped(sub))
|
|
return sub;
|
|
return NULL;
|
|
}
|
|
|
|
/* Show exactly one window of the tab: the focused stack member (the
|
|
wrapped main surface and stacked tab-group members are all plain members, so
|
|
no kind gets special treatment). Only the shown window's scene node
|
|
stays enabled -- hidden layers are neither drawn nor hit-tested, and a
|
|
member's popups (parented on its own tree) hide with it. */
|
|
static void show_current(struct kick_app *app) {
|
|
if (!app || !app->stack) return;
|
|
struct kick_tab_group_member *cur =
|
|
(server.active_app == app) ? app->focus_member : NULL;
|
|
struct kick_tab_group_member *m;
|
|
wl_list_for_each(m, &app->group_members, link)
|
|
wlr_scene_node_set_enabled(&m->tree->node, m == cur);
|
|
}
|
|
|
|
/* Give the keyboard to a stacked tab-group member of the active tab, demoting
|
|
whatever held it before (the main surface or an older tab-group member). */
|
|
static void member_enter(struct kick_tab_group_member *sub) {
|
|
struct kick_app *app = sub->owner;
|
|
if (app->focus_member == sub) return;
|
|
if (app->focus_member)
|
|
member_set_activated(app->focus_member, false);
|
|
app_set_activated(app, false);
|
|
member_set_activated(sub, true);
|
|
struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
|
|
if (!kbd) kbd = &server.keyboard;
|
|
wlr_seat_keyboard_notify_enter(server.seat,
|
|
member_surface(sub),
|
|
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
|
|
app->focus_member = sub;
|
|
show_current(app);
|
|
kick_request_redraw();
|
|
}
|
|
|
|
/* Give the keyboard to a window-stack member of the active tab -- a
|
|
stacked tab-group member via member_enter(), or the wrapped main surface, which
|
|
demotes whatever held the keyboard before and takes it itself. The
|
|
caller still calls show_current() to make the entered window the only
|
|
drawn one. */
|
|
static void group_enter(struct kick_tab_group_member *m) {
|
|
struct kick_app *app = m->owner;
|
|
if (!m->is_main) {
|
|
member_enter(m);
|
|
return;
|
|
}
|
|
if (app->focus_member)
|
|
member_set_activated(app->focus_member, false);
|
|
/* The wrapped main is the cursor even when its surface is not live
|
|
yet (a still-black tab): focus_member == the main member IS "the main
|
|
surface holds the keyboard". */
|
|
app->focus_member = m;
|
|
if (!app_has_window(app))
|
|
return;
|
|
app_set_activated(app, true);
|
|
struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
|
|
if (!kbd) kbd = &server.keyboard;
|
|
wlr_seat_keyboard_notify_enter(server.seat,
|
|
app_surface(app),
|
|
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
|
|
}
|
|
|
|
/* After the focused member unmaps or dies, hand the keyboard back to the
|
|
owner's next topmost mapped member -- newest stacked tab-group member, else
|
|
the wrapped main surface. No-op unless the owner is the tab being
|
|
shown. */
|
|
static void sub_refocus_owner(struct kick_app *app) {
|
|
if (server.active_app != app) return;
|
|
struct kick_tab_group_member *top = top_mapped_member(app);
|
|
if (top) {
|
|
/* Historic log shape: "[sub] refocus" marks the keyboard landing
|
|
back on the main surface after the last stacked window went
|
|
away; member-to-member handoffs stay silent. */
|
|
if (top->is_main)
|
|
kick_log(KICK_LOG_INFO, "[sub] refocus app=%d\n", app_index(app));
|
|
group_enter(top);
|
|
show_current(app);
|
|
kick_request_redraw();
|
|
return;
|
|
}
|
|
/* No live window anywhere in the stack: drop the keyboard entirely. */
|
|
app->focus_member = NULL;
|
|
show_current(app);
|
|
kick_request_redraw();
|
|
}
|
|
|
|
/* Rotate the active tab's window stack one step in `dir` (-1 = backward):
|
|
the plain list of members -- the wrapped main surface plus every mapped
|
|
stacked tab-group member, oldest first -- steps to the neighbor of whatever
|
|
is showing, wrapping at both ends. The list itself is never mutated;
|
|
focus_member alone tracks where the rotation sits, so repeated steps walk
|
|
the windows deterministically. Returns false when nothing is mapped
|
|
(nothing to rotate), leaving the key free to pass through to the
|
|
client. */
|
|
static bool cycle_group_members(struct kick_app *app, int dir) {
|
|
struct kick_tab_group_member *members[16];
|
|
struct kick_tab_group_member *m;
|
|
int n = 0;
|
|
wl_list_for_each(m, &app->group_members, link)
|
|
if (member_mapped(m) && n < 16) members[n++] = m;
|
|
|
|
if (n == 0)
|
|
return false;
|
|
|
|
int cur = 0;
|
|
while (cur < n && members[cur] != app->focus_member) cur++;
|
|
/* An unfocused tab position counts as the main surface (member 0),
|
|
so backward wraps to the newest window and forward to the oldest --
|
|
exactly where cycling picks up from a freshly shown tab. */
|
|
group_enter(members[(cur + dir + n) % n]);
|
|
|
|
show_current(app);
|
|
kick_log(KICK_LOG_INFO, "[sub] cycle app=%d\n", app_index(app));
|
|
kick_request_redraw();
|
|
return true;
|
|
}
|
|
|
|
static void handle_sub_commit(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_tab_group_member *sub = wl_container_of(listener, sub, commit);
|
|
if (sub->toplevel) {
|
|
struct wlr_xdg_toplevel *toplevel = sub->toplevel;
|
|
/* Same treatment as a tab's own surface: maximize and size to the
|
|
output on first commit, then keep the CSD-gutter position in sync. */
|
|
if (toplevel->base->initial_commit) {
|
|
wlr_xdg_toplevel_set_maximized(toplevel, true);
|
|
wlr_xdg_toplevel_set_size(toplevel, server.output->width,
|
|
server.output->height);
|
|
if (server.clients_fullscreen)
|
|
wlr_xdg_toplevel_set_fullscreen(toplevel, true);
|
|
}
|
|
position_tree(toplevel, sub->tree);
|
|
}
|
|
#if HAVE_XWAYLAND
|
|
else if (sub->is_x11 && sub->xsurface && sub->tree) {
|
|
/* Keep the window's content at the tab origin, cropping any
|
|
self-placement offset (mirrors x11_sync_position). */
|
|
struct wlr_xwayland_surface *xs = sub->xsurface;
|
|
int nx = (xs->x > 0 && xs->x < server.output->width) ? -xs->x : 0;
|
|
int ny = (xs->y > 0 && xs->y < server.output->height) ? -xs->y : 0;
|
|
wlr_scene_node_set_position(&sub->tree->node, nx, ny);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void handle_sub_map(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_tab_group_member *sub = wl_container_of(listener, sub, map);
|
|
sub->mapped = true;
|
|
#if HAVE_XWAYLAND
|
|
/* X11 tab-group members have no initial_commit; configure them to the
|
|
maximized output size on every map (idempotent). */
|
|
if (sub->is_x11 && sub->xsurface && sub->xsurface->surface) {
|
|
wlr_xwayland_surface_configure(sub->xsurface, 0, 0,
|
|
(uint16_t)server.output->width,
|
|
(uint16_t)server.output->height);
|
|
wlr_xwayland_surface_set_maximized(sub->xsurface, true, true);
|
|
if (server.clients_fullscreen)
|
|
wlr_xwayland_surface_set_fullscreen(sub->xsurface, true);
|
|
}
|
|
#endif
|
|
/* A tab-group member never steals focus: it appears with its owner tab and
|
|
takes the keyboard only when that tab is already the one shown. */
|
|
if (server.active_app == sub->owner) {
|
|
kick_log(KICK_LOG_INFO, "[sub] focus app=%d\n",
|
|
app_index(sub->owner));
|
|
member_enter(sub);
|
|
} else {
|
|
kick_log(KICK_LOG_INFO, "[sub] map app=%d\n", app_index(sub->owner));
|
|
}
|
|
}
|
|
|
|
static void handle_sub_unmap(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_tab_group_member *sub = wl_container_of(listener, sub, unmap);
|
|
sub->mapped = false;
|
|
if (sub->owner->focus_member == sub)
|
|
sub_refocus_owner(sub->owner);
|
|
}
|
|
|
|
/* Common tail of a stacked tab-group member's destroy handling: drop out of the
|
|
owner's list, restore the keyboard if it was held, free. */
|
|
static void member_destroy_common(struct kick_tab_group_member *sub) {
|
|
struct kick_app *app = sub->owner;
|
|
bool had_focus = app->focus_member == sub;
|
|
wl_list_remove(&sub->link);
|
|
kick_log(KICK_LOG_INFO, "[sub] unstack app=%d\n", app_index(app));
|
|
if (host.egl_ok && sub->icon_tex) {
|
|
glDeleteTextures(1, &sub->icon_tex);
|
|
sub->icon_tex = 0;
|
|
}
|
|
if (sub->icon_wlr) {
|
|
wlr_texture_destroy(sub->icon_wlr);
|
|
sub->icon_wlr = NULL;
|
|
}
|
|
if (had_focus) {
|
|
app->focus_member = NULL;
|
|
sub_refocus_owner(app);
|
|
}
|
|
free(sub);
|
|
}
|
|
|
|
static void handle_sub_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_tab_group_member *sub = wl_container_of(listener, sub, destroy);
|
|
wl_list_remove(&sub->destroy.link);
|
|
wl_list_remove(&sub->map.link);
|
|
wl_list_remove(&sub->unmap.link);
|
|
wl_list_remove(&sub->commit.link);
|
|
member_destroy_common(sub);
|
|
}
|
|
|
|
/* Wrap the tab's own main surface as an ordinary member of its window
|
|
stack ([main, oldest..newest tab-group members]): rotation, visibility and
|
|
overlay titles then read one plain list with no privileged entry. The
|
|
wrapper carries no listeners -- the tab-level map/commit/destroy
|
|
handlers stay authoritative -- and remove_app() frees it with the rest. */
|
|
static void group_wrap_main(struct kick_app *app) {
|
|
struct kick_tab_group_member *m = calloc(1, sizeof(*m));
|
|
if (!m || !app->tree) {
|
|
free(m);
|
|
return;
|
|
}
|
|
m->is_main = true;
|
|
m->owner = app;
|
|
m->tree = app->tree;
|
|
#if HAVE_XWAYLAND
|
|
if (app->is_x11) {
|
|
m->is_x11 = true;
|
|
m->xsurface = app->xsurface;
|
|
} else
|
|
#endif
|
|
m->toplevel = app->toplevel;
|
|
wl_list_insert(app->group_members.prev, &m->link);
|
|
}
|
|
|
|
/* Attach `toplevel` as a stacked tab-group member of `owner`: sibling of the
|
|
owner's main surface under its stack container (shows/hides with the
|
|
tab; show_current() keeps only the current window visible), same
|
|
maximize/size/position treatment as a tab. Never creates a slot. */
|
|
static void attach_group_member(struct kick_app *owner,
|
|
struct wlr_xdg_toplevel *toplevel) {
|
|
struct kick_tab_group_member *sub = calloc(1, sizeof(*sub));
|
|
if (!sub) return;
|
|
sub->toplevel = toplevel;
|
|
sub->owner = owner;
|
|
sub->tree = owner->stack
|
|
? wlr_scene_xdg_surface_create(owner->stack, toplevel->base) : NULL;
|
|
if (!sub->tree) {
|
|
free(sub);
|
|
return;
|
|
}
|
|
/* show_current() picks the visible window once it maps; start hidden. */
|
|
wlr_scene_node_set_enabled(&sub->tree->node, false);
|
|
sub->commit.notify = handle_sub_commit;
|
|
wl_signal_add(&toplevel->base->surface->events.commit, &sub->commit);
|
|
sub->map.notify = handle_sub_map;
|
|
wl_signal_add(&toplevel->base->surface->events.map, &sub->map);
|
|
sub->unmap.notify = handle_sub_unmap;
|
|
wl_signal_add(&toplevel->base->surface->events.unmap, &sub->unmap);
|
|
sub->destroy.notify = handle_sub_destroy;
|
|
wl_signal_add(&toplevel->events.destroy, &sub->destroy);
|
|
wl_list_insert(owner->group_members.prev, &sub->link);
|
|
kick_log(KICK_LOG_INFO, "[sub] stack app=%d\n", app_index(owner));
|
|
}
|
|
|
|
static void handle_toplevel_map(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, map);
|
|
struct wlr_xdg_toplevel *toplevel = app->toplevel;
|
|
if (kick_log_level() >= KICK_LOG_DEBUG)
|
|
kick_log(KICK_LOG_DEBUG, "[diag] map app=%d\n", app_index(app));
|
|
|
|
/* A new tab never steals focus on its own. The exceptions: a
|
|
Alt+Shift+T terminal (add_tab), a tab-group member whose parent tab is the
|
|
one currently shown, and the startup-selected last command-line tab
|
|
when it finally maps (it has been the active, black tab all along,
|
|
revealed the moment its first frame arrives). Every other map stays
|
|
hidden -- the overlay and cycling reach it, but it does not yank the
|
|
active surface. A slot's toplevel is set at claim time (before its
|
|
map), so "is it already shown?" is whether the stack container node
|
|
is enabled, not whether active_toplevel() is set. */
|
|
bool activate = false;
|
|
if (app->origin == ORIGIN_ADD_TAB)
|
|
activate = true;
|
|
else if (app->origin == ORIGIN_GROUP && app->parent_app &&
|
|
app->parent_app->toplevel == active_toplevel())
|
|
activate = true;
|
|
else if (app == server.active_app && !app_visible(app))
|
|
activate = true;
|
|
|
|
/* A slot claimed while it was still black may have been drawn with a
|
|
placeholder icon; once a real client maps it, re-resolve the icon so
|
|
the overlay picks up its app_id. */
|
|
app->icon_resolved = false;
|
|
|
|
if (activate) {
|
|
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;
|
|
|
|
pid_t cpid = -1;
|
|
if (toplevel->base->client)
|
|
wl_client_get_credentials(toplevel->base->client->client,
|
|
&cpid, NULL, NULL);
|
|
|
|
struct kick_app *app = NULL;
|
|
/* A slot awaiting its toplevel -- an unclaimed command-line command or
|
|
a fresh Alt+Shift+T terminal -- belongs to exactly ONE client: the one
|
|
WE launched for it. launch_client() runs each command in its own
|
|
process group (setpgid(0,0)), so the connected client's group id
|
|
equals the launcher pid app->pid, including through a shell wrapper
|
|
(`sh -c "foot -e htop"`). Matching by group -- not exact pid -- is
|
|
what keeps a wrapped command on its own command-line slot. A client
|
|
from a group we never created (an external one) never matches, so it
|
|
can't steal a still-loading tab's position. */
|
|
pid_t pgid = cpid > 0 ? getpgid(cpid) : -1;
|
|
for (int i = 0; i < server.num_apps && !app; i++) {
|
|
struct kick_app *a = server.apps[i];
|
|
if (a->toplevel || !a->pending_claim) continue;
|
|
if (a->pid > 0 && a->pid == pgid)
|
|
app = a;
|
|
}
|
|
/* A second toplevel from a client that already owns a tab is a tab-group member
|
|
of that tab: give it its own slot glued right after its parent (the
|
|
protocol parent, else the owning tab), never touching a reserved
|
|
pending slot. Command-line commands are all forked at startup (see
|
|
launch_all_cli); their clients match the pending loop above, and only
|
|
then. */
|
|
if (!app && cpid > 0) {
|
|
struct kick_app *owner = tab_group_owner(cpid);
|
|
if (owner) {
|
|
/* Stack mode: no slot at all -- the window rides on its
|
|
owner's tab, above its surface. */
|
|
if (tab_groups) {
|
|
attach_group_member(owner, toplevel);
|
|
return;
|
|
}
|
|
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
|
|
struct kick_app *sub = calloc(1, sizeof(*sub));
|
|
if (!sub) return;
|
|
sub->pid = -1;
|
|
sub->cmd = NULL;
|
|
sub->origin = ORIGIN_GROUP;
|
|
sub->parent_app = owner;
|
|
wl_list_init(&sub->group_members);
|
|
struct kick_app *pa = toplevel->parent
|
|
? app_for_toplevel(toplevel->parent) : NULL;
|
|
insert_app_after(sub, pa ? pa : owner);
|
|
app = sub;
|
|
}
|
|
}
|
|
/* No pending slot matched (an external client, or one from a group we
|
|
never created). Closed tabs are removed outright, so there are no
|
|
free holes to fall back into; this loop only catches a slot that is
|
|
transiently without a toplevel and not reserved, so it usually falls
|
|
through to the ORIGIN_EXTERNAL append below. */
|
|
if (!app)
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *a = server.apps[i];
|
|
if (!a->toplevel && !a->pending_claim && !a->closing) {
|
|
app = a;
|
|
break;
|
|
}
|
|
}
|
|
if (!app) {
|
|
/* No slot for this toplevel (an external client with every slot
|
|
taken). Give it its own tab so it appears on top and stays
|
|
reachable via tab cycling, right after the tab that created it
|
|
(protocol parent, else the active tab); far right if unidentified. */
|
|
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
|
|
struct kick_app *extra = calloc(1, sizeof(*extra));
|
|
if (!extra) return;
|
|
extra->pid = -1;
|
|
extra->cmd = NULL;
|
|
extra->origin = ORIGIN_EXTERNAL;
|
|
wl_list_init(&extra->group_members);
|
|
struct kick_app *pa = toplevel->parent
|
|
? app_for_toplevel(toplevel->parent) : NULL;
|
|
if (!pa)
|
|
pa = app_for_toplevel(active_toplevel());
|
|
insert_app_after(extra, pa);
|
|
app = extra;
|
|
}
|
|
|
|
app->toplevel = toplevel;
|
|
app->pending_claim = false;
|
|
app->stack = wlr_scene_tree_create(&server.scene->tree);
|
|
app->tree = app->stack
|
|
? wlr_scene_xdg_surface_create(app->stack, toplevel->base) : NULL;
|
|
/* hidden until it becomes the active tab */
|
|
if (app->stack)
|
|
wlr_scene_node_set_enabled(&app->stack->node, false);
|
|
group_wrap_main(app);
|
|
|
|
/* [claim] ties the app slot to the *connected client's* PID, so tests
|
|
can kill the tab that is actually active. The slot was chosen above by
|
|
the process-group match against a pending command (CLI or add_tab), by
|
|
the tab-group member/parent rule, or by the ORIGIN_EXTERNAL append. */
|
|
/* The client that actually occupies this slot may be the command we
|
|
forked, an add_tab() tab, or an external client. Record its process
|
|
group so a close signals the right one -- but only when that group is
|
|
one WE own. An externally started client (e.g. from the compositor's
|
|
own terminal) shares our process group; adopting it would make
|
|
close/shutdown group-signal the launcher's whole session. Such
|
|
clients are killed by pid. */
|
|
app->client_pid = cpid;
|
|
pid_t cpg = cpid > 0 ? getpgid(cpid) : -1;
|
|
app->pgid = (cpg > 0 && cpg != getpgid(0)) ? cpg : -1;
|
|
kick_log(KICK_LOG_INFO, "[claim] app=%d pid=%d\n",
|
|
app_index(app), (int)cpid);
|
|
|
|
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);
|
|
}
|
|
|
|
#if HAVE_XWAYLAND
|
|
/* ===================== XWayland (X11 tabs) ===================== */
|
|
/* An X11 window becomes a tab exactly like an xdg toplevel: claimed against
|
|
a pending command-line slot by process group, glued next to its owner as
|
|
a tab-group member, or appended as an external tab. Two structural differences
|
|
drive the code below:
|
|
- Every X11 window belongs to ONE Wayland client (the Xwayland process),
|
|
so client identity comes from xsurface->pid -- the real X client's PID
|
|
from _NET_WM_PID -- never from wl_client credentials.
|
|
- The wlr_surface only exists between the associate/dissociate events,
|
|
so the surface map/commit listeners are attached there, not at claim
|
|
time (dissociate always precedes destroy; a flag guards both orders).
|
|
Override-redirect windows (menus, tooltips) are handled further below:
|
|
they float above the tabs and never become slots. */
|
|
|
|
static void handle_xs_surface_map(struct wl_listener *listener, void *data);
|
|
static void handle_xs_commit(struct wl_listener *listener, void *data);
|
|
static void or_handle_map(struct wl_listener *listener, void *data);
|
|
static void or_handle_unmap(struct wl_listener *listener, void *data);
|
|
static void or_handle_commit(struct wl_listener *listener, void *data);
|
|
|
|
static struct kick_app *app_for_xsurface(struct wlr_xwayland_surface *xs) {
|
|
if (!xs) return NULL;
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (server.apps[i]->xsurface == xs)
|
|
return server.apps[i];
|
|
return NULL;
|
|
}
|
|
|
|
/* Keep a managed X11 window's content at the tab origin: kick configures
|
|
every tab to the full output at 0,0, so a client that placed itself
|
|
elsewhere before obeying gets its offset cropped (mirrors position_tree's
|
|
CSD-gutter handling). Offsets beyond the output are ignored rather than
|
|
blanking the whole tab. */
|
|
static void x11_sync_position(struct kick_app *app) {
|
|
struct wlr_xwayland_surface *xs = app->xsurface;
|
|
if (!xs || !app->tree || !server.output) return;
|
|
int nx = (xs->x > 0 && xs->x < server.output->width) ? -xs->x : 0;
|
|
int ny = (xs->y > 0 && xs->y < server.output->height) ? -xs->y : 0;
|
|
wlr_scene_node_set_position(&app->tree->node, nx, ny);
|
|
}
|
|
|
|
/* Maximize-and-size treatment shared by new X11 tabs and stacked X11
|
|
tab-group members (X11 has no initial_commit to hook, and reconfiguring on
|
|
every map is idempotent). */
|
|
static void x11_initial_configure(struct wlr_xwayland_surface *xs) {
|
|
if (!xs || !xs->surface || !server.output) return;
|
|
wlr_xwayland_surface_configure(xs, 0, 0,
|
|
(uint16_t)server.output->width,
|
|
(uint16_t)server.output->height);
|
|
wlr_xwayland_surface_set_maximized(xs, true, true);
|
|
if (server.clients_fullscreen)
|
|
wlr_xwayland_surface_set_fullscreen(xs, true);
|
|
}
|
|
|
|
/* --- Stacked X11 tab-group members (--tab-groups on) --- */
|
|
|
|
static void handle_sub_x_associate(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_tab_group_member *sub = wl_container_of(listener, sub, xs_associate);
|
|
if (!sub->xsurface || !sub->xsurface->surface || !sub->tree) return;
|
|
wlr_scene_surface_create(sub->tree, sub->xsurface->surface);
|
|
sub->map.notify = handle_sub_map;
|
|
wl_signal_add(&sub->xsurface->surface->events.map, &sub->map);
|
|
sub->unmap.notify = handle_sub_unmap;
|
|
wl_signal_add(&sub->xsurface->surface->events.unmap, &sub->unmap);
|
|
sub->commit.notify = handle_sub_commit;
|
|
wl_signal_add(&sub->xsurface->surface->events.commit, &sub->commit);
|
|
sub->xs_surface_linked = true;
|
|
}
|
|
|
|
static void handle_sub_x_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_tab_group_member *sub = wl_container_of(listener, sub, destroy);
|
|
wl_list_remove(&sub->destroy.link);
|
|
wl_list_remove(&sub->xs_associate.link);
|
|
if (sub->xs_surface_linked) {
|
|
wl_list_remove(&sub->map.link);
|
|
wl_list_remove(&sub->unmap.link);
|
|
wl_list_remove(&sub->commit.link);
|
|
sub->xs_surface_linked = false;
|
|
}
|
|
member_destroy_common(sub);
|
|
}
|
|
|
|
/* Attach an X11 transient as a stacked tab-group member: sibling of the owner's
|
|
main surface under its stack container, same lifecycle as
|
|
attach_group_member() but with the association-driven surface wiring of
|
|
this surface kind. */
|
|
static void attach_group_member_x11(struct kick_app *owner,
|
|
struct wlr_xwayland_surface *xsurface) {
|
|
struct kick_tab_group_member *sub = calloc(1, sizeof(*sub));
|
|
if (!sub) return;
|
|
sub->is_x11 = true;
|
|
sub->xsurface = xsurface;
|
|
sub->owner = owner;
|
|
sub->tree = owner->stack ? wlr_scene_tree_create(owner->stack) : NULL;
|
|
if (!sub->tree) {
|
|
free(sub);
|
|
return;
|
|
}
|
|
/* show_current() picks the visible window once it maps; start hidden. */
|
|
wlr_scene_node_set_enabled(&sub->tree->node, false);
|
|
if (xsurface->surface) {
|
|
/* Defensive: association normally happens after new_surface. */
|
|
handle_sub_x_associate(&sub->xs_associate, NULL);
|
|
}
|
|
sub->xs_associate.notify = handle_sub_x_associate;
|
|
wl_signal_add(&xsurface->events.associate, &sub->xs_associate);
|
|
sub->destroy.notify = handle_sub_x_destroy;
|
|
wl_signal_add(&xsurface->events.destroy, &sub->destroy);
|
|
wl_list_insert(owner->group_members.prev, &sub->link);
|
|
kick_log(KICK_LOG_INFO, "[sub] stack app=%d\n", app_index(owner));
|
|
}
|
|
|
|
/* --- Managed X11 tabs --- */
|
|
|
|
static void handle_xs_associate(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, xs_associate);
|
|
if (!app->xsurface || !app->xsurface->surface || !app->tree) return;
|
|
/* The scene node rendering the window's buffers; recreated per
|
|
association (the wlr_surface dies with it). */
|
|
wlr_scene_surface_create(app->tree, app->xsurface->surface);
|
|
app->map.notify = handle_xs_surface_map;
|
|
wl_signal_add(&app->xsurface->surface->events.map, &app->map);
|
|
app->commit.notify = handle_xs_commit;
|
|
wl_signal_add(&app->xsurface->surface->events.commit, &app->commit);
|
|
app->xs_surface_linked = true;
|
|
}
|
|
|
|
static void handle_xs_dissociate(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, xs_dissociate);
|
|
if (!app->xs_surface_linked) return;
|
|
wl_list_remove(&app->map.link);
|
|
wl_list_remove(&app->commit.link);
|
|
app->xs_surface_linked = false;
|
|
}
|
|
|
|
static void handle_xs_commit(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, commit);
|
|
x11_sync_position(app);
|
|
}
|
|
|
|
static void handle_xs_surface_map(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, map);
|
|
if (kick_log_level() >= KICK_LOG_DEBUG)
|
|
kick_log(KICK_LOG_DEBUG, "[diag] map app=%d\n", app_index(app));
|
|
|
|
x11_initial_configure(app->xsurface);
|
|
|
|
/* Same no-steal activation policy as handle_toplevel_map: an Alt+Shift+T
|
|
terminal, a tab-group member of the shown tab, or the startup-selected black
|
|
tab revealing itself. Everything else waits hidden in the overlay. */
|
|
bool activate = false;
|
|
if (app->origin == ORIGIN_ADD_TAB)
|
|
activate = true;
|
|
else if (app->origin == ORIGIN_GROUP && app->parent_app &&
|
|
app->parent_app == server.active_app)
|
|
activate = true;
|
|
else if (app == server.active_app && !app_visible(app))
|
|
activate = true;
|
|
|
|
/* A slot claimed while black may show a placeholder icon; re-resolve
|
|
against the now-known WM_CLASS. */
|
|
app->icon_resolved = false;
|
|
|
|
if (activate && !(app == server.active_app && app_visible(app)))
|
|
set_active_app(app);
|
|
}
|
|
|
|
static void handle_xs_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, destroy);
|
|
app->is_x11 = false;
|
|
app->xsurface = NULL;
|
|
app->tree = NULL;
|
|
app->stack = NULL;
|
|
|
|
/* Same overlay-damage and icon cleanup as handle_toplevel_destroy. */
|
|
if (host.overlay_visible && server.standalone)
|
|
standalone_overlay_damage();
|
|
if (app->icon_tex) {
|
|
glDeleteTextures(1, &app->icon_tex);
|
|
app->icon_tex = 0;
|
|
}
|
|
|
|
/* wlroots asserts every event listener list empty after destroy. */
|
|
wl_list_remove(&app->destroy.link);
|
|
wl_list_remove(&app->xs_associate.link);
|
|
wl_list_remove(&app->xs_dissociate.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->xs_request_configure.link);
|
|
wl_list_remove(&app->xs_request_activate.link);
|
|
wl_list_remove(&app->xs_request_close.link);
|
|
wl_list_remove(&app->xs_set_title.link);
|
|
wl_list_remove(&app->xs_set_class.link);
|
|
if (app->xs_surface_linked) {
|
|
wl_list_remove(&app->map.link);
|
|
wl_list_remove(&app->commit.link);
|
|
app->xs_surface_linked = false;
|
|
}
|
|
|
|
finish_app_destroy(app);
|
|
}
|
|
|
|
static void handle_xs_request_maximize(struct wl_listener *listener,
|
|
void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, request_maximize);
|
|
/* Every kick tab lives maximized; acknowledge the state change. */
|
|
if (app->xsurface)
|
|
wlr_xwayland_surface_set_maximized(app->xsurface, true, true);
|
|
}
|
|
|
|
static void handle_xs_request_minimize(struct wl_listener *listener,
|
|
void *data) {
|
|
(void)listener; (void)data;
|
|
/* kick has no minimized state: tabs are shown or hidden. */
|
|
}
|
|
|
|
static void handle_xs_request_fullscreen(struct wl_listener *listener,
|
|
void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, request_fullscreen);
|
|
struct wlr_xwayland_surface *xs = app->xsurface;
|
|
if (!xs) return;
|
|
/* Mirror onto the host window like the xdg path. */
|
|
bool fs = xs->fullscreen;
|
|
wlr_xwayland_surface_set_fullscreen(xs, fs);
|
|
kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", fs);
|
|
if (host.frame) {
|
|
if (fs)
|
|
libdecor_frame_set_fullscreen(host.frame, NULL);
|
|
else
|
|
libdecor_frame_unset_fullscreen(host.frame);
|
|
}
|
|
}
|
|
|
|
static void handle_xs_request_configure(struct wl_listener *listener,
|
|
void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app,
|
|
xs_request_configure);
|
|
/* The WM answer to any geometry request: maximized at the output size. */
|
|
x11_initial_configure(app->xsurface);
|
|
}
|
|
|
|
static void handle_xs_request_activate(struct wl_listener *listener,
|
|
void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, xs_request_activate);
|
|
/* An explicit activation request (the app's own window list, a pager)
|
|
may steal focus, exactly like Alt-cycling onto the tab. */
|
|
set_active_app(app);
|
|
}
|
|
|
|
static void handle_xs_request_close(struct wl_listener *listener, void *data) {
|
|
struct wlr_xwayland_surface *xs = data;
|
|
struct kick_app *app = wl_container_of(listener, app, xs_request_close);
|
|
/* A stacked tab-group X11 window closing on its own only unstacks -- the
|
|
member destroy path handles it -- so leave it be. Only the main
|
|
surface's close tears the whole tab down, matching Alt+Delete and the
|
|
Wayland behavior. */
|
|
struct kick_tab_group_member *sub;
|
|
wl_list_for_each(sub, &app->group_members, link)
|
|
if (sub->is_x11 && sub->xsurface == xs && !sub->is_main)
|
|
return;
|
|
close_tab(app);
|
|
}
|
|
|
|
static void handle_xs_set_title(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, xs_set_title);
|
|
/* Picked up by the overlay row on its next draw (row_title cache). */
|
|
(void)app;
|
|
}
|
|
|
|
static void handle_xs_set_class(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_app *app = wl_container_of(listener, app, xs_set_class);
|
|
/* Re-resolve the overlay icon against the new WM_CLASS. */
|
|
app->icon_resolved = false;
|
|
}
|
|
|
|
/* --- Override-redirect windows (menus, tooltips, popups) ---
|
|
Never tabs: they float above every tab tree at their absolute X position
|
|
while mapped, and take the keyboard only when their window type says they
|
|
want it (rofi-style launchers). */
|
|
|
|
struct kick_or_window {
|
|
struct wlr_xwayland_surface *xsurface;
|
|
struct wlr_scene_tree *tree;
|
|
bool surface_linked;
|
|
bool took_focus;
|
|
struct wl_listener destroy;
|
|
struct wl_listener associate;
|
|
struct wl_listener dissociate;
|
|
struct wl_listener map;
|
|
struct wl_listener unmap;
|
|
struct wl_listener commit;
|
|
struct wl_listener set_geometry;
|
|
struct wl_list link; /* server.or_windows */
|
|
};
|
|
|
|
static void or_window_position(struct kick_or_window *orw) {
|
|
struct wlr_xwayland_surface *xs = orw->xsurface;
|
|
if (!xs || !orw->tree) return;
|
|
/* Absolute layout coordinates: menus open where the app put them. */
|
|
wlr_scene_node_set_position(&orw->tree->node, xs->x, xs->y);
|
|
}
|
|
|
|
/* Hand the keyboard back to the tab that held it before this window took
|
|
it (or clear focus when nothing did). */
|
|
static void or_window_focus_release(struct kick_or_window *orw) {
|
|
if (!orw->took_focus) return;
|
|
orw->took_focus = false;
|
|
struct kick_app *owner = server.or_focus_owner;
|
|
server.or_focus_owner = NULL;
|
|
if (owner)
|
|
set_active_app(owner);
|
|
else
|
|
wlr_seat_keyboard_notify_clear_focus(server.seat);
|
|
}
|
|
|
|
static void or_handle_associate(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_or_window *orw = wl_container_of(listener, orw, associate);
|
|
if (!orw->xsurface || !orw->xsurface->surface || !orw->tree) return;
|
|
wlr_scene_surface_create(orw->tree, orw->xsurface->surface);
|
|
orw->map.notify = or_handle_map;
|
|
wl_signal_add(&orw->xsurface->surface->events.map, &orw->map);
|
|
orw->unmap.notify = or_handle_unmap;
|
|
wl_signal_add(&orw->xsurface->surface->events.unmap, &orw->unmap);
|
|
orw->commit.notify = or_handle_commit;
|
|
wl_signal_add(&orw->xsurface->surface->events.commit, &orw->commit);
|
|
orw->surface_linked = true;
|
|
or_window_position(orw);
|
|
}
|
|
|
|
static void or_handle_dissociate(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_or_window *orw = wl_container_of(listener, orw, dissociate);
|
|
if (!orw->surface_linked) return;
|
|
wl_list_remove(&orw->map.link);
|
|
wl_list_remove(&orw->unmap.link);
|
|
wl_list_remove(&orw->commit.link);
|
|
orw->surface_linked = false;
|
|
}
|
|
|
|
static void or_handle_map(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_or_window *orw = wl_container_of(listener, orw, map);
|
|
wlr_scene_node_set_enabled(&orw->tree->node, true);
|
|
or_window_position(orw);
|
|
if (wlr_xwayland_surface_override_redirect_wants_focus(orw->xsurface)) {
|
|
if (!server.or_focus_owner && server.active_app)
|
|
server.or_focus_owner = server.active_app;
|
|
orw->took_focus = true;
|
|
struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
|
|
if (!kbd) kbd = &server.keyboard;
|
|
wlr_seat_keyboard_notify_enter(server.seat,
|
|
orw->xsurface->surface,
|
|
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
|
|
}
|
|
kick_request_redraw();
|
|
}
|
|
|
|
static void or_handle_unmap(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_or_window *orw = wl_container_of(listener, orw, unmap);
|
|
wlr_scene_node_set_enabled(&orw->tree->node, false);
|
|
or_window_focus_release(orw);
|
|
kick_request_redraw();
|
|
}
|
|
|
|
static void or_handle_commit(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_or_window *orw = wl_container_of(listener, orw, commit);
|
|
or_window_position(orw);
|
|
}
|
|
|
|
static void or_handle_set_geometry(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_or_window *orw = wl_container_of(listener, orw, set_geometry);
|
|
or_window_position(orw);
|
|
}
|
|
|
|
static void or_handle_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_or_window *orw = wl_container_of(listener, orw, destroy);
|
|
wl_list_remove(&orw->destroy.link);
|
|
wl_list_remove(&orw->associate.link);
|
|
wl_list_remove(&orw->dissociate.link);
|
|
wl_list_remove(&orw->set_geometry.link);
|
|
if (orw->surface_linked) {
|
|
wl_list_remove(&orw->map.link);
|
|
wl_list_remove(&orw->unmap.link);
|
|
wl_list_remove(&orw->commit.link);
|
|
orw->surface_linked = false;
|
|
}
|
|
or_window_focus_release(orw);
|
|
wl_list_remove(&orw->link);
|
|
free(orw);
|
|
kick_request_redraw();
|
|
}
|
|
|
|
static void or_window_create(struct wlr_xwayland_surface *xsurface) {
|
|
struct kick_or_window *orw = calloc(1, sizeof(*orw));
|
|
if (!orw) return;
|
|
orw->xsurface = xsurface;
|
|
orw->tree = wlr_scene_tree_create(&server.scene->tree);
|
|
if (!orw->tree) {
|
|
free(orw);
|
|
return;
|
|
}
|
|
wlr_scene_node_set_enabled(&orw->tree->node, false);
|
|
orw->associate.notify = or_handle_associate;
|
|
wl_signal_add(&xsurface->events.associate, &orw->associate);
|
|
orw->dissociate.notify = or_handle_dissociate;
|
|
wl_signal_add(&xsurface->events.dissociate, &orw->dissociate);
|
|
orw->set_geometry.notify = or_handle_set_geometry;
|
|
wl_signal_add(&xsurface->events.set_geometry, &orw->set_geometry);
|
|
orw->destroy.notify = or_handle_destroy;
|
|
wl_signal_add(&xsurface->events.destroy, &orw->destroy);
|
|
if (xsurface->surface) {
|
|
/* Defensive: association normally happens after new_surface. */
|
|
or_handle_associate(&orw->associate, NULL);
|
|
}
|
|
wl_list_insert(server.or_windows.prev, &orw->link);
|
|
kick_log(KICK_LOG_INFO, "[x11] or window class=%s\n",
|
|
xsurface->class ? xsurface->class : "?");
|
|
}
|
|
|
|
static void handle_xwayland_new_surface(struct wl_listener *listener,
|
|
void *data) {
|
|
(void)listener;
|
|
struct wlr_xwayland_surface *xsurface = data;
|
|
|
|
if (xsurface->override_redirect) {
|
|
or_window_create(xsurface);
|
|
return;
|
|
}
|
|
|
|
/* The real X client's PID (_NET_WM_PID); 0 when the client didn't set
|
|
it, which lands the window in the external-append path below. */
|
|
pid_t xpid = xsurface->pid;
|
|
pid_t pgid = xpid > 0 ? getpgid(xpid) : -1;
|
|
|
|
/* Claim a pending slot by launcher process group: an X11 command forked
|
|
by launch_client inherits its group exactly like a Wayland client. */
|
|
struct kick_app *app = NULL;
|
|
for (int i = 0; i < server.num_apps && !app; i++) {
|
|
struct kick_app *a = server.apps[i];
|
|
if (app_has_window(a) || !a->pending_claim) continue;
|
|
if (a->pid > 0 && a->pid == pgid)
|
|
app = a;
|
|
}
|
|
/* A window from a process descended from a connected tab follows the
|
|
same tab-group member rules as a second xdg toplevel. */
|
|
if (!app && xpid > 0) {
|
|
struct kick_app *owner = tab_group_owner(xpid);
|
|
if (owner) {
|
|
if (tab_groups) {
|
|
attach_group_member_x11(owner, xsurface);
|
|
return;
|
|
}
|
|
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
|
|
struct kick_app *sub = calloc(1, sizeof(*sub));
|
|
if (!sub) return;
|
|
sub->pid = -1;
|
|
sub->cmd = NULL;
|
|
sub->origin = ORIGIN_GROUP;
|
|
sub->parent_app = owner;
|
|
wl_list_init(&sub->group_members);
|
|
struct kick_app *pa = app_for_xsurface(xsurface->parent);
|
|
insert_app_after(sub, pa ? pa : owner);
|
|
app = sub;
|
|
}
|
|
}
|
|
/* Transient hole scan, then the external append -- mirrors
|
|
handle_new_toplevel. */
|
|
if (!app)
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *a = server.apps[i];
|
|
if (!app_has_window(a) && !a->pending_claim && !a->closing) {
|
|
app = a;
|
|
break;
|
|
}
|
|
}
|
|
if (!app) {
|
|
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
|
|
struct kick_app *extra = calloc(1, sizeof(*extra));
|
|
if (!extra) return;
|
|
extra->pid = -1;
|
|
extra->cmd = NULL;
|
|
extra->origin = ORIGIN_EXTERNAL;
|
|
wl_list_init(&extra->group_members);
|
|
struct kick_app *pa = app_for_xsurface(xsurface->parent);
|
|
if (!pa)
|
|
pa = server.active_app;
|
|
insert_app_after(extra, pa);
|
|
app = extra;
|
|
}
|
|
|
|
app->is_x11 = true;
|
|
app->xsurface = xsurface;
|
|
app->pending_claim = false;
|
|
app->stack = wlr_scene_tree_create(&server.scene->tree);
|
|
app->tree = app->stack ? wlr_scene_tree_create(app->stack) : NULL;
|
|
if (!app->tree) {
|
|
if (app->stack) {
|
|
wlr_scene_node_destroy(&app->stack->node);
|
|
app->stack = NULL;
|
|
}
|
|
app->is_x11 = false;
|
|
app->xsurface = NULL;
|
|
return;
|
|
}
|
|
/* hidden until it becomes the active tab */
|
|
wlr_scene_node_set_enabled(&app->stack->node, false);
|
|
group_wrap_main(app);
|
|
|
|
app->client_pid = xpid;
|
|
pid_t cpg = xpid > 0 ? getpgid(xpid) : -1;
|
|
app->pgid = (cpg > 0 && cpg != getpgid(0)) ? cpg : -1;
|
|
kick_log(KICK_LOG_INFO, "[claim] app=%d pid=%d\n",
|
|
app_index(app), (int)xpid);
|
|
|
|
app->destroy.notify = handle_xs_destroy;
|
|
wl_signal_add(&xsurface->events.destroy, &app->destroy);
|
|
app->xs_associate.notify = handle_xs_associate;
|
|
wl_signal_add(&xsurface->events.associate, &app->xs_associate);
|
|
app->xs_dissociate.notify = handle_xs_dissociate;
|
|
wl_signal_add(&xsurface->events.dissociate, &app->xs_dissociate);
|
|
|
|
app->request_maximize.notify = handle_xs_request_maximize;
|
|
wl_signal_add(&xsurface->events.request_maximize, &app->request_maximize);
|
|
app->request_fullscreen.notify = handle_xs_request_fullscreen;
|
|
wl_signal_add(&xsurface->events.request_fullscreen,
|
|
&app->request_fullscreen);
|
|
app->request_minimize.notify = handle_xs_request_minimize;
|
|
wl_signal_add(&xsurface->events.request_minimize, &app->request_minimize);
|
|
app->xs_request_configure.notify = handle_xs_request_configure;
|
|
wl_signal_add(&xsurface->events.request_configure,
|
|
&app->xs_request_configure);
|
|
app->xs_request_activate.notify = handle_xs_request_activate;
|
|
wl_signal_add(&xsurface->events.request_activate,
|
|
&app->xs_request_activate);
|
|
app->xs_request_close.notify = handle_xs_request_close;
|
|
wl_signal_add(&xsurface->events.request_close, &app->xs_request_close);
|
|
app->xs_set_title.notify = handle_xs_set_title;
|
|
wl_signal_add(&xsurface->events.set_title, &app->xs_set_title);
|
|
app->xs_set_class.notify = handle_xs_set_class;
|
|
wl_signal_add(&xsurface->events.set_class, &app->xs_set_class);
|
|
}
|
|
|
|
static void handle_xwayland_ready(struct wl_listener *listener, void *data) {
|
|
(void)listener; (void)data;
|
|
kick_log(KICK_LOG_INFO, "[xwayland] ready display=%s\n",
|
|
server.xwayland && server.xwayland->display_name
|
|
? server.xwayland->display_name : "?");
|
|
}
|
|
|
|
static void handle_xwayland_destroy(struct wl_listener *listener, void *data) {
|
|
(void)listener; (void)data;
|
|
wl_list_remove(&server.xwayland_new_surface.link);
|
|
wl_list_remove(&server.xwayland_ready.link);
|
|
wl_list_remove(&server.xwayland_destroy.link);
|
|
server.xwayland = NULL;
|
|
kick_log(KICK_LOG_INFO, "[xwayland] gone\n");
|
|
}
|
|
#endif /* HAVE_XWAYLAND */
|
|
|
|
/* ===================== Tab management ===================== */
|
|
|
|
static struct kick_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];
|
|
/* A stacked tab-group member's toplevel belongs to its owner tab's client. */
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
struct kick_tab_group_member *sub;
|
|
wl_list_for_each(sub, &app->group_members, link)
|
|
if (sub->toplevel == toplevel)
|
|
return app;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* The tab whose client (by its wl_client) owns the given toplevel surface,
|
|
or NULL. Used to attribute a cursor-shape request to the requesting tab
|
|
so shapes are cached per app and reset on tab switches. */
|
|
static struct kick_app *app_for_client(struct wl_client *client) {
|
|
if (!client) return NULL;
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *app = server.apps[i];
|
|
if (app->toplevel &&
|
|
wl_resource_get_client(app->toplevel->base->surface->resource) == client)
|
|
return app;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static int app_index(struct kick_app *app) {
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (server.apps[i] == app)
|
|
return i;
|
|
return -1;
|
|
}
|
|
|
|
struct kick_popup {
|
|
struct wlr_xdg_surface *xdg_surface;
|
|
struct wlr_scene_tree *scene;
|
|
struct wl_listener commit;
|
|
struct wl_listener destroy;
|
|
struct wl_listener reposition;
|
|
};
|
|
|
|
/* 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;
|
|
}
|
|
}
|
|
|
|
/* Popups (menus, tooltips) are regular wlr_scene_xdg_surface nodes under
|
|
their parent's tree, so wlroots' scene renderer composites them into the
|
|
output buffer like any other surface. We only need to make sure the client
|
|
gets its initial configure (unconstrain from the output box) and to place
|
|
each popup under its parent's tree. */
|
|
static void popup_handle_commit(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_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 kick_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 kick_popup *tp = wl_container_of(listener, tp, destroy);
|
|
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 kick_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);
|
|
/* 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;
|
|
}
|
|
|
|
/* The scene tree a toplevel's popups must hang off: each stack member's
|
|
popups ride on that member's own tree (so they show only while it is
|
|
the current window and position within it) -- the wrapped main surface
|
|
is just the first member. */
|
|
static struct wlr_scene_tree *popup_tree_for_toplevel(
|
|
struct kick_app *app, struct wlr_xdg_toplevel *toplevel) {
|
|
if (!app || !toplevel) return NULL;
|
|
struct kick_tab_group_member *m;
|
|
wl_list_for_each(m, &app->group_members, link)
|
|
if (m->toplevel && m->toplevel == toplevel)
|
|
return m->tree;
|
|
return app->tree;
|
|
}
|
|
|
|
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 kick_app *app = app_for_toplevel(parent->toplevel);
|
|
parent_tree = popup_tree_for_toplevel(app, parent->toplevel);
|
|
} else if (parent->role == WLR_XDG_SURFACE_ROLE_POPUP) {
|
|
struct kick_popup *tp = parent->data;
|
|
if (tp) parent_tree = tp->scene;
|
|
}
|
|
}
|
|
if (!parent_tree) return;
|
|
create_popup(popup, parent_tree);
|
|
}
|
|
|
|
/* Make `app` the active slot. A mapped slot shows its surface and takes
|
|
keyboard focus; a pending (black) slot shows the backdrop and drops the
|
|
seat focus until it maps. The old active slot is hidden and deactivated.
|
|
Called with the same app as already active only to promote it black ->
|
|
mapped (the one case with something still to do), so callers that want a
|
|
true no-op must guard first. */
|
|
|
|
/* Apply a cursor-shape-v1 shape value end to end: in standalone mode set the
|
|
composited cursor image from the theme; in nested mode forward the shape to
|
|
the host pointer so the host draws the right cursor over our window. */
|
|
static void apply_cursor_shape(int shape) {
|
|
if (shape == 0 || shape == server.cursor_shape)
|
|
return;
|
|
server.cursor_shape = shape;
|
|
|
|
const char *name = wlr_cursor_shape_v1_name(
|
|
(enum wp_cursor_shape_device_v1_shape)shape);
|
|
if (server.standalone) {
|
|
if (server.cursor && server.xcursor_mgr)
|
|
wlr_cursor_set_xcursor(server.cursor, server.xcursor_mgr, name);
|
|
kick_log(KICK_LOG_DEBUG, "[cursor] shape %s\n", name);
|
|
} else {
|
|
host_cursor_shape_set(shape);
|
|
}
|
|
}
|
|
|
|
/* A nested client switched its cursor shape via cursor-shape-v1. Cache the
|
|
shape on the requesting tab (so switching tabs resets to that tab's own
|
|
last shape, or default) and apply it. */
|
|
static void handle_shape_set(struct wl_listener *listener, void *data) {
|
|
(void)listener;
|
|
struct wlr_cursor_shape_manager_v1_request_set_shape_event *ev = data;
|
|
if (ev->device_type != WLR_CURSOR_SHAPE_MANAGER_V1_DEVICE_TYPE_POINTER)
|
|
return;
|
|
struct kick_app *app = app_for_client(ev->seat_client->client);
|
|
if (app)
|
|
app->cursor_shape = ev->shape;
|
|
apply_cursor_shape(ev->shape);
|
|
}
|
|
|
|
static void set_active_app(struct kick_app *app) {
|
|
if (!app) return;
|
|
|
|
struct kick_app *old = server.active_app;
|
|
if (old && old != app) {
|
|
/* A stacked tab-group member holding the keyboard loses it with the
|
|
tab, but we keep the pointer so returning to this tab restores the
|
|
window that was focused when we left (not always the newest one). */
|
|
if (old->focus_member)
|
|
member_set_activated(old->focus_member, false);
|
|
if (old->toplevel) {
|
|
/* Hide the whole window stack (main surface + tab-group members);
|
|
show_current() re-picks the visible window when it returns. */
|
|
if (old->stack)
|
|
wlr_scene_node_set_enabled(&old->stack->node, false);
|
|
wlr_xdg_toplevel_set_activated(old->toplevel, false);
|
|
}
|
|
}
|
|
server.active_app = app;
|
|
|
|
if (app_has_window(app)) {
|
|
app_set_activated(app, true);
|
|
position_toplevel(app);
|
|
if (app->stack)
|
|
wlr_scene_node_set_enabled(&app->stack->node, true);
|
|
if (kick_log_level() >= KICK_LOG_DEBUG) {
|
|
struct wlr_surface *s = app_surface(app);
|
|
kick_log(KICK_LOG_DEBUG, "[diag] active app=%d surface=%dx%d "
|
|
"node on\n", app_index(app),
|
|
s ? s->current.width : 0, s ? s->current.height : 0);
|
|
}
|
|
} else if (kick_log_level() >= KICK_LOG_DEBUG) {
|
|
kick_log(KICK_LOG_DEBUG, "[diag] active slot=%d (black, awaiting map)\n",
|
|
app_index(app));
|
|
}
|
|
|
|
/* In standalone the seat keyboard is the emitting native device (set by
|
|
the standalone handlers); fall back to the virtual keyboard otherwise
|
|
so the nested/ctl path keeps using it. A black active tab has no
|
|
surface to enter, so clear the focus instead of leaving a stale one.
|
|
The tab's window stack hands the keyboard to its last-focused member
|
|
when it is still mapped, otherwise to the topmost mapped member
|
|
(newest stacked tab-group member, else the wrapped main surface) -- so
|
|
returning to a tab lands back on the window you were last on. */
|
|
struct kick_tab_group_member *enter =
|
|
(app->focus_member && member_mapped(app->focus_member))
|
|
? app->focus_member : top_mapped_member(app);
|
|
if (enter) {
|
|
/* Only genuine tab-group member entries announce themselves; entering
|
|
the wrapped main surface is every plain tab's quiet default. */
|
|
if (!enter->is_main)
|
|
kick_log(KICK_LOG_INFO, "[sub] focus app=%d\n", app_index(app));
|
|
group_enter(enter);
|
|
} else {
|
|
app->focus_member = NULL;
|
|
wlr_seat_keyboard_notify_clear_focus(server.seat);
|
|
}
|
|
|
|
/* The keyboard settle above picked the tab's current window; now make
|
|
it the only drawn one (main surface hidden while a sub holds the
|
|
stack, other subs hidden behind the current one). */
|
|
show_current(app);
|
|
|
|
/* The newly-active tab owns the cursor shape: apply its last requested
|
|
shape (or default when it never asked) so a switched-from tab's shape
|
|
never bleeds onto the next tab. */
|
|
apply_cursor_shape(app->cursor_shape > 0
|
|
? app->cursor_shape
|
|
: WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT);
|
|
kick_request_redraw();
|
|
}
|
|
|
|
static void set_active_surface(struct wlr_xdg_toplevel *toplevel) {
|
|
struct kick_app *app = app_for_toplevel(toplevel);
|
|
if (!app) return;
|
|
/* Promoting the already-active black tab to mapped is the one same-app
|
|
call that must still run; everything else that's a true no-op returns
|
|
here, so redundant activations emit no [diag] churn. */
|
|
if (app == server.active_app && app->toplevel && app_visible(app))
|
|
return;
|
|
set_active_app(app);
|
|
}
|
|
|
|
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] == server.active_app) {
|
|
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 (app_live(server.apps[j]) && !server.apps[j]->closing) {
|
|
set_active_app(server.apps[j]);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Move the active tab one position up (dir>0: later in the list) or down
|
|
(dir<0) within the overlay order, wrapping at the ends. The active
|
|
surface is unchanged; only its slot in server.apps[] moves, which the
|
|
overlay and cycling both iterate. Only live slots (mapped or pending
|
|
black, mirroring cycle_tab) that aren't mid-close count as positions;
|
|
with fewer than two of them there is nowhere to move. */
|
|
static void reorder_active_tab(int dir) {
|
|
int n = 0;
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing)
|
|
n++;
|
|
if (n < 2) return;
|
|
int from = -1;
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (server.apps[i] == server.active_app) {
|
|
from = i;
|
|
break;
|
|
}
|
|
if (from < 0) return;
|
|
|
|
int neighbor = -1;
|
|
if (dir > 0) {
|
|
for (int i = from + 1; i < server.num_apps; i++)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
|
neighbor = i;
|
|
break;
|
|
}
|
|
if (neighbor < 0)
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
|
neighbor = i;
|
|
break;
|
|
}
|
|
} else {
|
|
for (int i = from - 1; i >= 0; i--)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
|
neighbor = i;
|
|
break;
|
|
}
|
|
if (neighbor < 0)
|
|
for (int i = server.num_apps - 1; i >= 0; i--)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
|
neighbor = i;
|
|
break;
|
|
}
|
|
}
|
|
if (neighbor < 0 || neighbor == from) return;
|
|
|
|
/* Move the tab to `neighbor`'s slot and shift every slot between the two
|
|
one position, so the middle of the list moves with it. For adjacent
|
|
tabs this is just a swap, but when `neighbor` wraps around to the far
|
|
end (from is first/last of the live list) the other live tabs must all
|
|
shuffle one place rather than staying put. */
|
|
struct kick_app *moved = server.apps[from];
|
|
if (neighbor < from) {
|
|
memmove(&server.apps[neighbor + 1], &server.apps[neighbor],
|
|
(size_t)(from - neighbor) * sizeof(*server.apps));
|
|
server.apps[neighbor] = moved;
|
|
} else {
|
|
memmove(&server.apps[from], &server.apps[from + 1],
|
|
(size_t)(neighbor - from) * sizeof(*server.apps));
|
|
server.apps[neighbor] = moved;
|
|
}
|
|
|
|
kick_log(KICK_LOG_INFO, "[reorder] app=%d dir=%d\n",
|
|
neighbor, dir);
|
|
kick_request_redraw();
|
|
if (server.standalone)
|
|
standalone_overlay_damage();
|
|
}
|
|
|
|
/* Fixed keybind repeat: first repeat after 600ms, then every 40ms (25Hz). */
|
|
#define KICK_REPEAT_DELAY_MS 600
|
|
#define KICK_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;
|
|
if (host.repeat_kind == REPEAT_REORDER)
|
|
reorder_active_tab(host.repeat_dir);
|
|
else if (host.repeat_kind == REPEAT_SUBCYCLE)
|
|
cycle_group_members(server.active_app, host.repeat_dir);
|
|
else
|
|
cycle_tab(host.repeat_dir);
|
|
if (host.repeat_timer)
|
|
wl_event_source_timer_update(host.repeat_timer,
|
|
KICK_REPEAT_INTERVAL_MS);
|
|
return 0;
|
|
}
|
|
|
|
static void kbd_repeat_start(int key, int dir, int kind) {
|
|
kbd_repeat_cancel();
|
|
host.repeat_key = key;
|
|
host.repeat_dir = dir;
|
|
host.repeat_kind = kind;
|
|
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, KICK_REPEAT_DELAY_MS);
|
|
}
|
|
|
|
/* ===================== 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 double bridge_dbg_ms(void);
|
|
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);
|
|
static bool bridge_primary_prefetch_mime_compatible(const char *mime);
|
|
static void bridge_primary_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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-src] t=%.0f inner_send mime=%s inner_src=%p "
|
|
"prefetch_ready=%d dc_offer=%p dd_offer=%p\n", bridge_dbg_ms(),
|
|
mime, (void *)bridge.inner_src, bridge.prefetch_ready,
|
|
(void *)bridge.dc_offer, (void *)bridge.dd_offer);
|
|
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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-bridge] t=%.0f inner_src destroyed\n",
|
|
bridge_dbg_ms());
|
|
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));
|
|
}
|
|
|
|
/* ---------------- primary selection mirror (host <-> nested seat) -------- */
|
|
|
|
/* The nested primary-selection source we install when a host offer is
|
|
mirrored in. A nested client asking for the data has the request pulled
|
|
straight from the host offer over the data-control pipe. */
|
|
static void bridge_inner_primary_source_send(struct wlr_primary_selection_source *src,
|
|
const char *mime, int32_t fd) {
|
|
(void)src;
|
|
if (bridge_primary_prefetch_mime_compatible(mime)) {
|
|
bridge_primary_prefetch_serve(mime, fd);
|
|
return;
|
|
}
|
|
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_primary_offer) {
|
|
zwlr_data_control_offer_v1_receive(bridge.dc_primary_offer, mime, fd);
|
|
close(fd);
|
|
} else {
|
|
close(fd);
|
|
}
|
|
}
|
|
|
|
static void bridge_inner_primary_source_destroy(struct wlr_primary_selection_source *src) {
|
|
free(src);
|
|
}
|
|
|
|
static const struct wlr_primary_selection_source_impl bridge_inner_primary_source_impl = {
|
|
.send = bridge_inner_primary_source_send,
|
|
.destroy = bridge_inner_primary_source_destroy,
|
|
};
|
|
|
|
static void bridge_inner_primary_src_destroy(struct wl_listener *l, void *data) {
|
|
(void)data;
|
|
wl_list_remove(&l->link);
|
|
bridge.inner_primary_src = NULL;
|
|
}
|
|
|
|
static void bridge_set_inner_primary_src(struct wlr_primary_selection_source *src) {
|
|
if (bridge.inner_primary_src == src)
|
|
return;
|
|
if (bridge.inner_primary_src) {
|
|
wl_list_remove(&bridge.inner_primary_src_destroy.link);
|
|
bridge.inner_primary_src = NULL;
|
|
}
|
|
bridge.inner_primary_src = src;
|
|
if (src) {
|
|
bridge.inner_primary_src_destroy.notify = bridge_inner_primary_src_destroy;
|
|
wl_signal_add(&src->events.destroy, &bridge.inner_primary_src_destroy);
|
|
}
|
|
}
|
|
|
|
/* Put the collected host primary offer (bridge.mimes) into the nested seat. */
|
|
static void bridge_mirror_primary_to_nested(void) {
|
|
kick_log(KICK_LOG_DEBUG, "[bridge] mirror host primary into nested "
|
|
"(mimes=%zu)\n", bridge.mimes_len);
|
|
struct wlr_primary_selection_source *src = calloc(1, sizeof(*src));
|
|
if (!src) {
|
|
bridge_mimes_reset();
|
|
return;
|
|
}
|
|
wlr_primary_selection_source_init(src, &bridge_inner_primary_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;
|
|
wlr_seat_set_primary_selection(server.seat, src,
|
|
wl_display_next_serial(server.wl_display));
|
|
}
|
|
|
|
static void bridge_primary_clear_nested(void) {
|
|
if (!server.seat->primary_selection_source)
|
|
return;
|
|
kick_log(KICK_LOG_DEBUG, "[bridge] clear nested primary\n");
|
|
bridge.primary_ignore_next_inner = true;
|
|
wlr_seat_set_primary_selection(server.seat, NULL,
|
|
wl_display_next_serial(server.wl_display));
|
|
}
|
|
|
|
/* ---------------- text prefetch: serve pastes from a buffered copy -------- */
|
|
|
|
#define KICK_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 < KICK_PREFETCH_MAX) {
|
|
size_t ncap = bridge.prefetch_cap ? bridge.prefetch_cap * 2 : 8192;
|
|
if (ncap > KICK_PREFETCH_MAX)
|
|
ncap = KICK_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);
|
|
}
|
|
|
|
/* ---------------- primary-selection text prefetch ----------------------- */
|
|
|
|
#define KICK_PRIMARY_PREFETCH_MAX KICK_PREFETCH_MAX
|
|
|
|
static void bridge_primary_prefetch_cancel(void) {
|
|
if (bridge.primary_prefetch_src) {
|
|
wl_event_source_remove(bridge.primary_prefetch_src);
|
|
bridge.primary_prefetch_src = NULL;
|
|
}
|
|
if (bridge.primary_prefetch_fd >= 0) {
|
|
close(bridge.primary_prefetch_fd);
|
|
bridge.primary_prefetch_fd = -1;
|
|
}
|
|
free(bridge.primary_prefetch_buf);
|
|
bridge.primary_prefetch_buf = NULL;
|
|
bridge.primary_prefetch_len = bridge.primary_prefetch_cap = 0;
|
|
bridge.primary_prefetch_mime[0] = '\0';
|
|
bridge.primary_prefetch_ready = false;
|
|
}
|
|
|
|
static bool bridge_primary_prefetch_drain(int fd) {
|
|
for (;;) {
|
|
size_t room = bridge.primary_prefetch_cap - bridge.primary_prefetch_len;
|
|
if (bridge.primary_prefetch_len + 4096 > bridge.primary_prefetch_cap &&
|
|
bridge.primary_prefetch_cap < KICK_PRIMARY_PREFETCH_MAX) {
|
|
size_t ncap = bridge.primary_prefetch_cap
|
|
? bridge.primary_prefetch_cap * 2 : 8192;
|
|
if (ncap > KICK_PRIMARY_PREFETCH_MAX)
|
|
ncap = KICK_PRIMARY_PREFETCH_MAX;
|
|
char *nb = realloc(bridge.primary_prefetch_buf, ncap);
|
|
if (nb) {
|
|
bridge.primary_prefetch_buf = nb;
|
|
bridge.primary_prefetch_cap = ncap;
|
|
room = bridge.primary_prefetch_cap - bridge.primary_prefetch_len;
|
|
}
|
|
}
|
|
if (room == 0) {
|
|
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.primary_prefetch_buf + bridge.primary_prefetch_len,
|
|
room);
|
|
if (n > 0) {
|
|
bridge.primary_prefetch_len += (size_t)n;
|
|
continue;
|
|
}
|
|
if (n == 0)
|
|
return true;
|
|
if (errno == EINTR)
|
|
continue;
|
|
if (errno == EAGAIN)
|
|
return false;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
static int bridge_primary_prefetch_cb(int fd, uint32_t mask, void *data) {
|
|
(void)data; (void)mask;
|
|
if (!bridge_primary_prefetch_drain(fd))
|
|
return 0;
|
|
if (bridge.primary_prefetch_src) {
|
|
wl_event_source_remove(bridge.primary_prefetch_src);
|
|
bridge.primary_prefetch_src = NULL;
|
|
}
|
|
close(fd);
|
|
bridge.primary_prefetch_fd = -1;
|
|
bridge.primary_prefetch_ready = true;
|
|
return 0;
|
|
}
|
|
|
|
/* Start pulling the current host primary offer's text MIME into our buffer.
|
|
Must be called while bridge.mimes holds the freshly collected list. */
|
|
static void bridge_primary_prefetch_start(void) {
|
|
bridge_primary_prefetch_cancel();
|
|
if (bridge.backend != BRIDGE_BACKEND_DATA_CONTROL || !bridge.dc_primary_offer)
|
|
return;
|
|
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.primary_prefetch_fd = p[0];
|
|
bridge.primary_prefetch_src = wl_event_loop_add_fd(server.loop, p[0],
|
|
WL_EVENT_READABLE, bridge_primary_prefetch_cb, NULL);
|
|
if (!bridge.primary_prefetch_src) {
|
|
close(p[0]);
|
|
close(p[1]);
|
|
bridge.primary_prefetch_fd = -1;
|
|
return;
|
|
}
|
|
snprintf(bridge.primary_prefetch_mime, sizeof(bridge.primary_prefetch_mime),
|
|
"%s", mime);
|
|
zwlr_data_control_offer_v1_receive(bridge.dc_primary_offer, mime, p[1]);
|
|
close(p[1]);
|
|
}
|
|
|
|
/* The requested MIME can be served from the prefetched primary-text buffer. */
|
|
static bool bridge_primary_prefetch_mime_compatible(const char *mime) {
|
|
if (!bridge.primary_prefetch_ready || bridge.primary_prefetch_mime[0] == '\0')
|
|
return false;
|
|
if (strcmp(mime, bridge.primary_prefetch_mime) == 0)
|
|
return true;
|
|
bool a = strncmp(mime, "text/plain", 10) == 0 ||
|
|
strcmp(mime, "UTF8_STRING") == 0;
|
|
bool b = strncmp(bridge.primary_prefetch_mime, "text/plain", 10) == 0 ||
|
|
strcmp(bridge.primary_prefetch_mime, "UTF8_STRING") == 0;
|
|
return a && b;
|
|
}
|
|
|
|
static void bridge_primary_prefetch_serve(const char *mime, int32_t fd) {
|
|
if (!bridge_primary_prefetch_mime_compatible(mime)) {
|
|
close(fd);
|
|
return;
|
|
}
|
|
size_t off = 0;
|
|
while (off < bridge.primary_prefetch_len) {
|
|
ssize_t n = write(fd, bridge.primary_prefetch_buf + off,
|
|
bridge.primary_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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dc_source_send mime=%s "
|
|
"fd=%d inner_src=%p\n", bridge_dbg_ms(), mime, fd,
|
|
(void *)bridge.inner_src);
|
|
if (bridge.inner_src) {
|
|
wlr_data_source_send(bridge.inner_src, mime, fd);
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dc_source_send "
|
|
"forwarded mime=%s fd=%d\n", bridge_dbg_ms(), 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;
|
|
bridge.dc_pending_echo = false;
|
|
bridge.dd_pending_echo = false;
|
|
}
|
|
|
|
static const struct zwlr_data_control_source_v1_listener bridge_dc_source_listener = {
|
|
.send = bridge_dc_source_send,
|
|
.cancelled = bridge_dc_source_cancelled,
|
|
};
|
|
|
|
/* Our published host primary-selection source: host middle-click requests
|
|
are served by asking the originating nested client for the data. */
|
|
static void bridge_dc_primary_source_send(void *data,
|
|
struct zwlr_data_control_source_v1 *src, const char *mime, int32_t fd) {
|
|
(void)data; (void)src;
|
|
if (bridge.inner_primary_src)
|
|
wlr_primary_selection_source_send(bridge.inner_primary_src, mime, fd);
|
|
else
|
|
close(fd);
|
|
}
|
|
|
|
static void bridge_dc_primary_source_cancelled(void *data,
|
|
struct zwlr_data_control_source_v1 *src) {
|
|
(void)data;
|
|
zwlr_data_control_source_v1_destroy(src);
|
|
bridge.dc_primary_source = NULL;
|
|
bridge.dc_primary_pending_echo = false;
|
|
}
|
|
|
|
static const struct zwlr_data_control_source_v1_listener bridge_dc_primary_source_listener = {
|
|
.send = bridge_dc_primary_source_send,
|
|
.cancelled = bridge_dc_primary_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_primary_offer) {
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_primary_offer);
|
|
bridge.dc_primary_offer = NULL;
|
|
}
|
|
if (bridge.dc_primary_echo_offer) {
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_primary_echo_offer);
|
|
bridge.dc_primary_echo_offer = NULL;
|
|
}
|
|
if (bridge.dc_primary_source) {
|
|
zwlr_data_control_source_v1_destroy(bridge.dc_primary_source);
|
|
bridge.dc_primary_source = NULL;
|
|
}
|
|
bridge.dc_primary_pending_echo = false;
|
|
bridge_primary_prefetch_cancel();
|
|
if (bridge.dc_device) {
|
|
zwlr_data_control_device_v1_destroy(bridge.dc_device);
|
|
bridge.dc_device = NULL;
|
|
}
|
|
kick_log(KICK_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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dc data_offer %p pending_echo=%d "
|
|
"primary_pending=%d\n", bridge_dbg_ms(), (void *)offer,
|
|
bridge.dc_pending_echo, bridge.dc_primary_pending_echo);
|
|
if (bridge.dc_pending_echo) {
|
|
bridge.dc_pending_echo = false;
|
|
bridge.dc_echo_offer = offer;
|
|
} else if (bridge.dc_primary_pending_echo) {
|
|
bridge.dc_primary_pending_echo = false;
|
|
bridge.dc_primary_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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dc selection offer=%p pending_echo=%d "
|
|
"echo_offer=%p\n", bridge_dbg_ms(), (void *)offer,
|
|
bridge.dc_pending_echo, (void *)bridge.dc_echo_offer);
|
|
bridge_prefetch_cancel();
|
|
/* Destroying our own previous source in bridge_host_set_selection makes the
|
|
compositor briefly emit selection(NULL) before the echo of the new source;
|
|
it is not a real clear, so ignore it while our echo is still pending. */
|
|
if (!offer && bridge.dc_pending_echo)
|
|
return;
|
|
if (offer && offer == bridge.dc_echo_offer) {
|
|
bridge.dc_echo_offer = NULL;
|
|
bridge_mimes_reset();
|
|
zwlr_data_control_offer_v1_destroy(offer);
|
|
bridge.dc_pending_echo = false;
|
|
return;
|
|
}
|
|
bridge.dc_pending_echo = false;
|
|
bridge.dd_pending_echo = false;
|
|
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();
|
|
}
|
|
|
|
/* Primary selection bridged over the same data-control device (v2): the
|
|
host's primary_selection event mirrors the host's middle-click selection
|
|
into the nested seat (and the echo of our own set is swallowed). */
|
|
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 && offer == bridge.dc_primary_echo_offer) {
|
|
bridge.dc_primary_echo_offer = NULL;
|
|
bridge.dc_primary_pending_echo = false;
|
|
bridge_mimes_reset();
|
|
zwlr_data_control_offer_v1_destroy(offer);
|
|
kick_log(KICK_LOG_DEBUG, "[bridge] primary echo swallowed\n");
|
|
return;
|
|
}
|
|
if (offer == NULL) {
|
|
/* Like the clipboard's selection(NULL): destroying our previous source
|
|
in bridge_host_set_primary_selection makes the compositor briefly
|
|
emit primary_selection(NULL) before the echo of the new one, which is
|
|
not a real clear -- let pending stay armed for the echo's data_offer. */
|
|
if (bridge.dc_primary_pending_echo)
|
|
return;
|
|
bridge.dc_primary_offer = NULL;
|
|
bridge_mimes_reset();
|
|
bridge_primary_prefetch_cancel();
|
|
bridge_primary_clear_nested();
|
|
return;
|
|
}
|
|
bridge.dc_primary_pending_echo = false;
|
|
if (bridge.dc_primary_offer)
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_primary_offer);
|
|
bridge.dc_primary_offer = offer;
|
|
kick_log(KICK_LOG_DEBUG, "[bridge] host primary_selection offer\n");
|
|
/* The offer's mimes were collected by the preceding data_offer/offer
|
|
events; the data_offer handler has already reset bridge.mimes. */
|
|
bridge_primary_prefetch_start();
|
|
bridge_mirror_primary_to_nested();
|
|
}
|
|
|
|
static const struct zwlr_data_control_device_v1_listener bridge_dc_listener = {
|
|
.data_offer = bridge_dc_device_data_offer,
|
|
.selection = bridge_dc_device_selection,
|
|
.primary_selection = bridge_dc_device_primary_selection,
|
|
.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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dd_source_send mime=%s fd=%d "
|
|
"inner_src=%p\n", bridge_dbg_ms(), mime, fd,
|
|
(void *)bridge.inner_src);
|
|
if (bridge.inner_src) {
|
|
wlr_data_source_send(bridge.inner_src, mime, fd);
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dd_source_send "
|
|
"forwarded mime=%s fd=%d\n", bridge_dbg_ms(), 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;
|
|
bridge.dc_pending_echo = false;
|
|
bridge.dd_pending_echo = false;
|
|
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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dd data_offer %p pending_echo=%d\n",
|
|
bridge_dbg_ms(), (void *)offer, bridge.dd_pending_echo);
|
|
if (bridge.dd_pending_echo) {
|
|
bridge.dd_pending_echo = false;
|
|
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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-host] t=%.0f dd selection offer=%p pending_echo=%d "
|
|
"echo_offer=%p\n", bridge_dbg_ms(), (void *)offer,
|
|
bridge.dd_pending_echo, (void *)bridge.dd_echo_offer);
|
|
if (!offer) {
|
|
/* Focus blip: the host selection is momentarily gone (or we lost
|
|
keyboard focus). Keep the last-known nested selection and, above
|
|
all, leave the pending-echo flags intact so an in-flight dc/dd
|
|
set_selection reply is still recognized as our own echo. */
|
|
bridge_mimes_reset();
|
|
return;
|
|
}
|
|
bridge_prefetch_cancel();
|
|
if (offer == bridge.dd_echo_offer) {
|
|
bridge.dd_echo_offer = NULL;
|
|
bridge_mimes_reset();
|
|
wl_data_offer_destroy(offer);
|
|
bridge.dd_pending_echo = false;
|
|
return;
|
|
}
|
|
bridge.dc_pending_echo = false;
|
|
bridge.dd_pending_echo = false;
|
|
if (bridge.dd_offer)
|
|
wl_data_offer_destroy(bridge.dd_offer);
|
|
bridge.dd_offer = offer;
|
|
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_dbg_src_destroyed(struct wl_listener *l, void *data) {
|
|
(void)data;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-src] t=%.0f nested selection source destroyed\n",
|
|
bridge_dbg_ms());
|
|
wl_list_remove(&l->link);
|
|
free(l);
|
|
}
|
|
|
|
static double bridge_dbg_ms(void) {
|
|
struct timespec ts;
|
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6;
|
|
}
|
|
|
|
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);
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-bridge] t=%.0f request_set_selection "
|
|
"src=%p impl=%p serial=%u\n", bridge_dbg_ms(), (void *)event->source,
|
|
(void *)(event->source ? event->source->impl : NULL),
|
|
event->serial);
|
|
if (event->source) {
|
|
struct wl_listener *dl = calloc(1, sizeof(*dl));
|
|
if (dl) {
|
|
dl->notify = bridge_dbg_src_destroyed;
|
|
wl_signal_add(&event->source->events.destroy, dl);
|
|
}
|
|
}
|
|
wlr_seat_set_selection(server.seat, event->source, event->serial);
|
|
}
|
|
|
|
static void seat_request_set_primary_selection(struct wl_listener *l,
|
|
void *data) {
|
|
(void)l;
|
|
struct wlr_seat_request_set_primary_selection_event *event = data;
|
|
bridge.inner_primary_clear_requested = (event->source == NULL);
|
|
wlr_seat_set_primary_selection(server.seat, event->source, event->serial);
|
|
}
|
|
|
|
static void bridge_host_set_selection(struct wlr_data_source *src) {
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-bridge] t=%.0f host_set_selection src=%p impl=%p "
|
|
"backend=%d dc_device=%p mimes=%zu\n", bridge_dbg_ms(),
|
|
(void *)src, (void *)(src ? src->impl : NULL), bridge.backend,
|
|
(void *)bridge.dc_device,
|
|
src ? src->mime_types.size / sizeof(char *) : 0);
|
|
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;
|
|
bridge.dc_pending_echo = true;
|
|
bridge.dd_pending_echo = true;
|
|
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;
|
|
kick_log(KICK_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;
|
|
bridge.dc_pending_echo = true;
|
|
bridge.dd_pending_echo = true;
|
|
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_host_set_primary_selection(struct wlr_primary_selection_source *src) {
|
|
bridge_set_inner_primary_src(src);
|
|
/* A nested client is overriding whatever host primary was mirrored in, so
|
|
the mirror's prefetch is stale. */
|
|
bridge_primary_prefetch_cancel();
|
|
|
|
if (bridge.backend != BRIDGE_BACKEND_DATA_CONTROL || !bridge.dc_device
|
|
|| !bridge.dc_primary_supported)
|
|
return; /* wl_data_device / v1 data-control have no primary channel */
|
|
if (bridge.dc_primary_source) {
|
|
zwlr_data_control_source_v1_destroy(bridge.dc_primary_source);
|
|
bridge.dc_primary_source = NULL;
|
|
}
|
|
if (!src)
|
|
return;
|
|
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_primary_source_listener, NULL);
|
|
bridge.dc_primary_source = s;
|
|
bridge.dc_primary_pending_echo = true;
|
|
zwlr_data_control_device_v1_set_primary_selection(bridge.dc_device, s);
|
|
kick_log(KICK_LOG_INFO, "clipboard bridge: primary selection -> host\n");
|
|
}
|
|
|
|
static void bridge_host_primary_clear(void) {
|
|
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_device
|
|
&& bridge.dc_primary_supported)
|
|
zwlr_data_control_device_v1_set_primary_selection(bridge.dc_device, NULL);
|
|
}
|
|
|
|
static void bridge_seat_set_primary_selection(struct wl_listener *l, void *data) {
|
|
(void)l; (void)data;
|
|
struct wlr_primary_selection_source *src = server.seat->primary_selection_source;
|
|
if (src && src->impl == &bridge_inner_primary_source_impl)
|
|
return; /* our own host->nested mirror */
|
|
if (bridge.primary_ignore_next_inner) {
|
|
bridge.primary_ignore_next_inner = false;
|
|
return;
|
|
}
|
|
if (!src) {
|
|
if (bridge.inner_primary_clear_requested) {
|
|
bridge.inner_primary_clear_requested = false;
|
|
bridge_host_primary_clear();
|
|
}
|
|
return;
|
|
}
|
|
bridge.inner_primary_clear_requested = false;
|
|
bridge_host_set_primary_selection(src);
|
|
}
|
|
|
|
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;
|
|
kick_log(KICK_LOG_DEBUG, "[dbg-bridge] t=%.0f seat set_selection src=%p "
|
|
"impl=%p inner_mirror=%d ignore_next=%d inner_clear_requested=%d\n",
|
|
bridge_dbg_ms(), (void *)src, (void *)(src ? src->impl : NULL),
|
|
src && src->impl == &bridge_inner_source_impl,
|
|
bridge.ignore_next_inner, bridge.inner_clear_requested);
|
|
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);
|
|
bridge.set_primary_selection.notify = bridge_seat_set_primary_selection;
|
|
wl_signal_add(&server.seat->events.set_primary_selection,
|
|
&bridge.set_primary_selection);
|
|
}
|
|
|
|
static void bridge_init(void) {
|
|
if (!host.seat) {
|
|
kick_log(KICK_LOG_INFO, "clipboard bridge: no host seat; disabled\n");
|
|
return;
|
|
}
|
|
/* Re-entrant: drop any objects bound to a previous host seat so a runtime
|
|
seat re-add (registry churn, e.g. VT round-trip) re-binds cleanly. */
|
|
if (bridge.dd)
|
|
wl_data_device_release(bridge.dd);
|
|
bridge.dd = NULL;
|
|
if (bridge.dc_device)
|
|
zwlr_data_control_device_v1_destroy(bridge.dc_device);
|
|
bridge.dc_device = NULL;
|
|
if (bridge.dc_primary_source) {
|
|
zwlr_data_control_source_v1_destroy(bridge.dc_primary_source);
|
|
bridge.dc_primary_source = NULL;
|
|
}
|
|
if (bridge.dc_primary_offer) {
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_primary_offer);
|
|
bridge.dc_primary_offer = NULL;
|
|
}
|
|
if (bridge.dc_primary_echo_offer) {
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_primary_echo_offer);
|
|
bridge.dc_primary_echo_offer = NULL;
|
|
}
|
|
bridge.dc_primary_pending_echo = false;
|
|
bridge_primary_prefetch_cancel();
|
|
bridge.dd_offer = NULL;
|
|
bridge.dc_offer = NULL;
|
|
bridge.dd_echo_offer = NULL;
|
|
bridge.dc_echo_offer = NULL;
|
|
bridge.backend = BRIDGE_BACKEND_NONE;
|
|
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;
|
|
kick_log(KICK_LOG_INFO, "clipboard bridge: host data-control backend "
|
|
"(clipboard and primary selection bridged)\n");
|
|
} else if (bridge.dd) {
|
|
bridge.backend = BRIDGE_BACKEND_WL_DATA_DEVICE;
|
|
kick_log(KICK_LOG_INFO, "clipboard bridge: focus-based wl_data_device "
|
|
"backend (host lacks wlr-data-control; host->nested sync needs "
|
|
"focus; primary selection not bridged)\n");
|
|
} else {
|
|
kick_log(KICK_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.inner_primary_src) {
|
|
wl_list_remove(&bridge.inner_primary_src_destroy.link);
|
|
bridge.inner_primary_src = NULL;
|
|
}
|
|
if (bridge.dc_source) {
|
|
zwlr_data_control_source_v1_destroy(bridge.dc_source);
|
|
bridge.dc_source = NULL;
|
|
}
|
|
if (bridge.dc_primary_source) {
|
|
zwlr_data_control_source_v1_destroy(bridge.dc_primary_source);
|
|
bridge.dc_primary_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.dc_primary_offer) {
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_primary_offer);
|
|
bridge.dc_primary_offer = NULL;
|
|
}
|
|
if (bridge.dc_primary_echo_offer) {
|
|
zwlr_data_control_offer_v1_destroy(bridge.dc_primary_echo_offer);
|
|
bridge.dc_primary_echo_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_primary_prefetch_cancel();
|
|
bridge.backend = BRIDGE_BACKEND_NONE;
|
|
}
|
|
|
|
/* ===================== Host-side listeners ===================== */
|
|
|
|
/* The host compositor draws the nested cursor over our window, so a nested
|
|
client's cursor-shape-v1 request is forwarded to the host's
|
|
wp_cursor_shape_device_v1 (bound to our host wl_pointer). The host only
|
|
honors the shape while the pointer sits over our surface and ignores
|
|
requests with a stale serial, hence host.ptr_serial. When the host lacks
|
|
cursor-shape-v1 this is a no-op and the host keeps its default cursor. */
|
|
static void host_cursor_shape_get_pointer(void) {
|
|
if (!host.shape_mgr || !host.pointer) {
|
|
host.shape_dev = NULL;
|
|
return;
|
|
}
|
|
/* get_pointer = the sole new_id request (opcode 1); the interface symbol
|
|
comes from libwlr (we must not also include the generated client header
|
|
here: both server and client headers define the shape enum). */
|
|
host.shape_dev = (struct wp_cursor_shape_device_v1 *)wl_proxy_marshal_flags(
|
|
(struct wl_proxy *)host.shape_mgr, 1 /* WP_CURSOR_SHAPE_MANAGER_V1_GET_POINTER */,
|
|
&wp_cursor_shape_device_v1_interface,
|
|
wl_proxy_get_version((struct wl_proxy *)host.shape_mgr),
|
|
0, NULL, (struct wl_proxy *)host.pointer);
|
|
}
|
|
|
|
static void host_cursor_shape_set(int shape) {
|
|
if (!host.shape_dev) {
|
|
kick_log(KICK_LOG_DEBUG, "[cursor] host shape n/a\n");
|
|
return;
|
|
}
|
|
wl_proxy_marshal_flags((struct wl_proxy *)host.shape_dev,
|
|
1 /* WP_CURSOR_SHAPE_DEVICE_V1_SET_SHAPE */, NULL,
|
|
wl_proxy_get_version((struct wl_proxy *)host.shape_dev),
|
|
0, host.ptr_serial, (uint32_t)shape);
|
|
kick_log(KICK_LOG_DEBUG, "[cursor] host shape %s\n",
|
|
wlr_cursor_shape_v1_name((enum wp_cursor_shape_device_v1_shape)shape));
|
|
}
|
|
|
|
/* wl_seat capabilities handler: see host_seat_capabilities() below (defined
|
|
after the host keyboard/pointer listeners it installs). */
|
|
static void host_seat_capabilities(void *data, struct wl_seat *seat,
|
|
uint32_t capabilities);
|
|
static void host_seat_name(void *data, struct wl_seat *seat, const char *name) {
|
|
(void)data; (void)seat; (void)name;
|
|
}
|
|
static const struct wl_seat_listener host_seat_listener = {
|
|
.capabilities = host_seat_capabilities,
|
|
.name = host_seat_name,
|
|
};
|
|
|
|
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);
|
|
} else if (strcmp(interface, wl_seat_interface.name) == 0) {
|
|
/* Bind the seat and rely on the capabilities event (which the
|
|
compositor always sends after a bind) to (re)create the keyboard
|
|
and pointer objects -- including after a VT round-trip, where the
|
|
compositor invalidates seat objects and a re-bind or capability
|
|
re-gain must be observed to keep receiving input. */
|
|
host.seat = wl_registry_bind(registry, name,
|
|
&wl_seat_interface, version < 5 ? version : 5);
|
|
host.seat_name = name;
|
|
wl_seat_add_listener(host.seat, &host_seat_listener, NULL);
|
|
if (host.registry_synced)
|
|
bridge_init(); /* runtime seat re-add: re-establish the bridge */
|
|
} else if (strcmp(interface, wp_cursor_shape_manager_v1_interface.name) == 0) {
|
|
host.shape_mgr = wl_registry_bind(registry, name,
|
|
&wp_cursor_shape_manager_v1_interface,
|
|
version < 1 ? version : 1);
|
|
kick_log(KICK_LOG_DEBUG, "[cursor] host has cursor-shape-v1\n");
|
|
} 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);
|
|
bridge.dc_primary_supported =
|
|
wl_proxy_get_version((struct wl_proxy *)bridge.dc_manager) >= 2;
|
|
if (!bridge.dc_primary_supported)
|
|
kick_log(KICK_LOG_INFO, "clipboard bridge: host wlr-data-control "
|
|
"v1 only; primary selection not bridged\n");
|
|
}
|
|
}
|
|
|
|
static void host_registry_global_remove(void *data, struct wl_registry *registry,
|
|
uint32_t name)
|
|
{
|
|
(void)data; (void)registry;
|
|
/* Only the wl_seat global's removal matters to us: its children (keyboard,
|
|
pointer, clipboard data devices) are invalidated with it and must be
|
|
re-created when the compositor advertises a new seat. Other globals we
|
|
bind (compositor, xdg_wm_base, ...) are process-lifetime. */
|
|
if (host.seat_name && name == host.seat_name) {
|
|
kick_log(KICK_LOG_DEBUG, "[seat] global %u removed\n", name);
|
|
host.seat_name = 0;
|
|
host.seat = NULL;
|
|
host.keyboard = NULL;
|
|
host.pointer = NULL;
|
|
host.shape_dev = NULL; /* inert with the removed seat's pointer */
|
|
/* Seat-owned bridge objects are invalidated too; null them so the
|
|
next bridge_init() on a fresh seat re-binds cleanly. */
|
|
bridge.dd = NULL;
|
|
bridge.dc_device = NULL;
|
|
bridge.dd_offer = NULL;
|
|
bridge.dc_offer = NULL;
|
|
bridge.dd_echo_offer = NULL;
|
|
bridge.dc_echo_offer = NULL;
|
|
bridge.dc_primary_source = NULL;
|
|
bridge.dc_primary_offer = NULL;
|
|
bridge.dc_primary_echo_offer = NULL;
|
|
bridge_primary_prefetch_cancel();
|
|
bridge.backend = BRIDGE_BACKEND_NONE;
|
|
}
|
|
}
|
|
|
|
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_resize(int width, int height) {
|
|
if (width < 50) width = 50;
|
|
if (height < 50) height = 50;
|
|
if (width == host.width && height == host.height) return;
|
|
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);
|
|
kick_request_redraw();
|
|
}
|
|
|
|
/* Tear down the nested window: drop the libdecor frame (and its decoration
|
|
subsurfaces) and the content surface. Only called from the exit paths, so
|
|
no ack/commit dance is needed. */
|
|
static void host_teardown_window(void) {
|
|
if (host.frame) {
|
|
libdecor_frame_unref(host.frame);
|
|
host.frame = NULL;
|
|
}
|
|
if (host.surface) {
|
|
wl_surface_destroy(host.surface);
|
|
host.surface = NULL;
|
|
}
|
|
}
|
|
|
|
static void host_frame_configure(struct libdecor_frame *frame,
|
|
struct libdecor_configuration *config,
|
|
void *data)
|
|
{
|
|
(void)data;
|
|
int width = 0, height = 0;
|
|
if (!libdecor_configuration_get_content_size(config, frame, &width, &height)) {
|
|
/* The initial configure commonly offers no size (the compositor
|
|
lets a CSD client pick one). The old xdg_toplevel configure
|
|
handler also treated 0x0 as "keep the current size": commit the
|
|
current layout anyway so libdecor gets a real sized configure
|
|
once we present a buffer. */
|
|
width = host.width;
|
|
height = host.height;
|
|
}
|
|
enum libdecor_window_state state = 0;
|
|
bool have_state = libdecor_configuration_get_window_state(config, &state);
|
|
if (have_state) {
|
|
bool fs = (state & LIBDECOR_WINDOW_STATE_FULLSCREEN) != 0;
|
|
if (fs != host.fullscreen) {
|
|
kick_log(KICK_LOG_INFO, "host fullscreen=%d\n", fs);
|
|
clients_set_fullscreen(fs);
|
|
}
|
|
host.fullscreen = fs;
|
|
}
|
|
/* libdecor reports the *content* size; the host window wraps that content
|
|
with its decorations. The content is exactly our wlr output. */
|
|
if (width > 0 && height > 0)
|
|
host_resize(width, height);
|
|
host.configured = true;
|
|
struct libdecor_state *st = libdecor_state_new(width, height);
|
|
libdecor_frame_commit(frame, st, config);
|
|
libdecor_state_free(st);
|
|
if (host.surface)
|
|
wl_surface_commit(host.surface);
|
|
}
|
|
|
|
static void host_frame_close(struct libdecor_frame *frame, void *data) {
|
|
(void)frame; (void)data;
|
|
kick_log(KICK_LOG_INFO, "kick: window closed\n");
|
|
host_teardown_window();
|
|
shutdown_cleanup();
|
|
exit(0);
|
|
}
|
|
|
|
static void host_frame_commit(struct libdecor_frame *frame, void *data) {
|
|
/* Decoration via (synchronous) subsurfaces: libdecor asks us to commit the
|
|
content surface so the decoration appears atomically with the content. */
|
|
(void)frame; (void)data;
|
|
if (host.surface)
|
|
wl_surface_commit(host.surface);
|
|
}
|
|
|
|
static struct libdecor_frame_interface host_frame_iface = {
|
|
.configure = host_frame_configure,
|
|
.close = host_frame_close,
|
|
.commit = host_frame_commit,
|
|
};
|
|
|
|
static void host_libdecor_error(struct libdecor *ctx, enum libdecor_error error,
|
|
const char *message)
|
|
{
|
|
(void)ctx;
|
|
fprintf(stderr, "kick: libdecor error %d: %s\n", (int)error,
|
|
message ? message : "(null)");
|
|
}
|
|
|
|
static struct libdecor_interface host_libdecor_iface = {
|
|
.error = host_libdecor_error,
|
|
};
|
|
|
|
/* ---------- host keyboard ---------- */
|
|
|
|
/* (Re)build the xkb_state used to translate raw evdev keycodes into
|
|
keysyms for host hotkey matching. The state is derived from whatever
|
|
keymap is current (host-provided or fallback), so the hotkeys work on
|
|
any keyboard/layout without hardcoded keycodes. */
|
|
static void xkb_state_reload(void)
|
|
{
|
|
if (server.xkb_state) {
|
|
xkb_state_unref(server.xkb_state);
|
|
server.xkb_state = NULL;
|
|
}
|
|
if (!server.xkb_keymap)
|
|
return;
|
|
server.xkb_state = xkb_state_new(server.xkb_keymap);
|
|
server.xkb_prefix_mod = xkb_keymap_mod_get_index(server.xkb_keymap,
|
|
tab_prefix == PREFIX_SUPER ? XKB_PREFIX_MOD4_NAME
|
|
: XKB_MOD_NAME_ALT);
|
|
kick_log(KICK_LOG_DEBUG, "kick: prefix modifier index=%u "
|
|
"(%s)\n", (unsigned)server.xkb_prefix_mod,
|
|
tab_prefix == PREFIX_SUPER ? "Super/Mod4" : "Alt");
|
|
}
|
|
|
|
static void host_kbd_keymap(void *data, struct wl_keyboard *kbd, uint32_t format,
|
|
int32_t fd, uint32_t size)
|
|
{
|
|
(void)data; (void)kbd;
|
|
if (format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1 || size == 0 || !server.xkb_ctx) {
|
|
close(fd);
|
|
return;
|
|
}
|
|
kick_log(KICK_LOG_DEBUG, "kick: 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;
|
|
}
|
|
}
|
|
buf[total] = '\0';
|
|
if (total < size)
|
|
kick_log(KICK_LOG_DEBUG,
|
|
"kick: 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;
|
|
xkb_state_reload();
|
|
kick_log(KICK_LOG_DEBUG, "kick: host keymap parsed OK\n");
|
|
} else {
|
|
kick_log(KICK_LOG_INFO, "kick: failed to parse host "
|
|
"keymap (%u bytes); keeping fallback keymap\n", size);
|
|
if (kick_log_level() >= KICK_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]);
|
|
kick_log(KICK_LOG_DEBUG, "kick: keymap head (%zu/%u "
|
|
"bytes): %s\n", n, size, hex);
|
|
}
|
|
}
|
|
|
|
if (mapped)
|
|
munmap(buf, size);
|
|
else
|
|
free(buf);
|
|
close(fd);
|
|
}
|
|
|
|
static void host_kbd_enter(void *data, struct wl_keyboard *kbd, uint32_t serial,
|
|
struct wl_surface *surface, struct wl_array *keys)
|
|
{
|
|
(void)data; (void)kbd; (void)surface; (void)keys;
|
|
/* Remember the enter serial for host clipboard set_selection. Only the
|
|
focus-based wl_data_device backend can hold a stale echo guard (a
|
|
set_selection sent with a no-longer-valid serial gets no reply): drop it
|
|
on refocus there. The data-control backend echoes independently of
|
|
focus, so its in-flight guard must be preserved here, or the host's echo
|
|
of our own set_selection is mistaken for a real selection and mirrored
|
|
back into an endless set_selection/receive loop. */
|
|
bridge.kbd_serial = serial;
|
|
if (bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE) {
|
|
bridge.dc_pending_echo = false;
|
|
bridge.dd_pending_echo = false;
|
|
}
|
|
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);
|
|
}
|
|
kick_log(KICK_LOG_DEBUG, "[kbd] enter surface=%p serial=%u active=%p\n",
|
|
(void *)surface, serial, (void *)active_toplevel());
|
|
struct kick_app *app = server.active_app;
|
|
if (app_has_window(app)) {
|
|
wlr_seat_keyboard_notify_enter(server.seat, app_surface(app),
|
|
server.keyboard.keycodes, server.keyboard.num_keycodes,
|
|
&server.keyboard.modifiers);
|
|
/* A stacked tab-group member may hold the keyboard within the shown tab. */
|
|
if (app->focus_member && member_surface(app->focus_member))
|
|
wlr_seat_keyboard_notify_enter(server.seat,
|
|
member_surface(app->focus_member),
|
|
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;
|
|
kick_log(KICK_LOG_DEBUG, "[kbd] leave surface=%p serial=%u\n",
|
|
(void *)surface, serial);
|
|
wlr_seat_keyboard_notify_clear_focus(server.seat);
|
|
}
|
|
|
|
/* 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,
|
|
&data, &format, &stride)) {
|
|
kick_log(KICK_LOG_INFO, "[dump] scene buffer not CPU-readable "
|
|
"(%dx%d)\n", buf->width, buf->height);
|
|
return;
|
|
}
|
|
/* [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++;
|
|
}
|
|
}
|
|
kick_log(KICK_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));
|
|
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);
|
|
kick_log(KICK_LOG_INFO, "[dump] scene buffer -> %s (%dx%d, "
|
|
"fmt=0x%x)\n", path, w, h, format);
|
|
}
|
|
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;
|
|
/* The blit draws are queued on the GPU; without a finish the readback can
|
|
race the previous frame's swap and return stale/mid-clear content. */
|
|
glFinish();
|
|
glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, px);
|
|
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++;
|
|
}
|
|
kick_log(KICK_LOG_INFO, "[dump] host %dx%d nonblack=%lu/%lu mean=%lu\n",
|
|
w, h, nonblack, (unsigned long)w * h,
|
|
lum / ((unsigned long)w * h + 1));
|
|
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);
|
|
kick_log(KICK_LOG_INFO, "[dump] host back buffer -> %s (%dx%d)\n",
|
|
path, w, h);
|
|
}
|
|
free(px);
|
|
dump_seq++;
|
|
}
|
|
|
|
/* Dump the standalone frame buffer as it will be presented (scene + overlay
|
|
composited by the wlroots renderer). With a GLES2 renderer we glReadPixels
|
|
the buffer's FBO in the wlroots EGL context; otherwise fall back to a CPU
|
|
read (e.g. the shm-backed buffers of the pixman renderer). Uses the same
|
|
"[dump] host back buffer ->" message as the nested dump so tests can parse
|
|
either mode with one helper. */
|
|
static void dump_standalone_buffer(struct wlr_buffer *buf) {
|
|
int w = buf->width, h = buf->height;
|
|
unsigned char *px = malloc((size_t)w * h * 4);
|
|
if (!px) return;
|
|
bool flip = false;
|
|
struct wlr_egl *egl = wlr_gles2_renderer_get_egl(server.renderer);
|
|
GLuint fbo = egl ? wlr_gles2_renderer_get_buffer_fbo(server.renderer, buf)
|
|
: 0;
|
|
EGLDisplay d = egl ? wlr_egl_get_display(egl) : EGL_NO_DISPLAY;
|
|
EGLContext c = egl ? wlr_egl_get_context(egl) : EGL_NO_CONTEXT;
|
|
if (d != EGL_NO_DISPLAY && c != EGL_NO_CONTEXT && fbo) {
|
|
eglMakeCurrent(d, EGL_NO_SURFACE, EGL_NO_SURFACE, c);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
|
glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, px);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
eglMakeCurrent(d, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
|
flip = true;
|
|
} else {
|
|
void *data;
|
|
uint32_t format;
|
|
size_t stride;
|
|
if (!wlr_buffer_begin_data_ptr_access(buf,
|
|
WLR_BUFFER_DATA_PTR_ACCESS_READ, &data, &format, &stride)) {
|
|
free(px);
|
|
kick_log(KICK_LOG_INFO,
|
|
"[dump] standalone buffer not CPU-readable\n");
|
|
return;
|
|
}
|
|
for (int y = 0; y < h; y++)
|
|
memcpy(px + (size_t)y * w * 4, data + (size_t)y * stride,
|
|
(size_t)w * 4);
|
|
wlr_buffer_end_data_ptr_access(buf);
|
|
}
|
|
|
|
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++;
|
|
}
|
|
kick_log(KICK_LOG_INFO, "[dump] host %dx%d nonblack=%lu/%lu mean=%lu\n",
|
|
w, h, nonblack, (unsigned long)w * h,
|
|
lum / ((unsigned long)w * h + 1));
|
|
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 = 0; y < h; y++) {
|
|
int sy = flip ? h - 1 - y : y;
|
|
unsigned char *row = px + (size_t)sy * 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);
|
|
kick_log(KICK_LOG_INFO, "[dump] host back buffer -> %s (%dx%d)\n",
|
|
path, w, h);
|
|
if (server.cursor)
|
|
kick_log(KICK_LOG_INFO,
|
|
"[dump] cursor at (%f,%f)\n", server.cursor->x, server.cursor->y);
|
|
}
|
|
free(px);
|
|
dump_seq++;
|
|
}
|
|
|
|
/* KICK_SYNTH_INPUT=1: ignore the real host keyboard entirely and accept
|
|
input only from the stdin event feed (the test/driver channel). The host
|
|
keyboard listener stays attached (its enter/leave events still drive the
|
|
clipboard bridge), but key and modifier events from the real host are
|
|
dropped so a stray physical press can't cancel a synthesized repeat or
|
|
clobber the injected modifier state. */
|
|
static bool synth_input_only;
|
|
|
|
/* Forward declarations for the shared handlers (defined just below
|
|
host_kbd_key / host_ptr_motion). */
|
|
static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
|
|
uint32_t mods_depressed);
|
|
static void process_modifiers(uint32_t mods_depressed, uint32_t mods_latched,
|
|
uint32_t mods_locked, uint32_t group);
|
|
static void host_ptr_motion(uint32_t time, double sx, double sy);
|
|
|
|
static void host_kbd_key(void *data, struct wl_keyboard *kbd, uint32_t serial,
|
|
uint32_t time, uint32_t key, uint32_t state)
|
|
{
|
|
(void)data;
|
|
if (synth_input_only && kbd)
|
|
return;
|
|
(void)kbd;
|
|
bridge.kbd_serial = serial;
|
|
if (!process_key_event(time, key, state, host.host_mods_depressed)) {
|
|
/* forward the key to our clients, like a native device would */
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* Standalone overlay visibility. The overlay is drawn into the scene output
|
|
buffer outside the scene graph, so the scene's damage trackers never learn
|
|
about its pixels; a swapchain buffer reused after the overlay was drawn
|
|
keeps the stale overlay baked in unless we force a repaint on transition.
|
|
Bumping the sentinel rect's size routes through wlr_scene_rect_set_size ->
|
|
wlr_scene_node_update() with NULL damage, which re-damages the node's whole
|
|
populated "visible" region, so both swapchain slots repaint within the next
|
|
two frames: the overlay fully appears on press and fully clears on release. */
|
|
static void standalone_overlay_damage(void) {
|
|
if (!server.output || !server.overlay_damage_rect) return;
|
|
struct wlr_scene_rect *r = server.overlay_damage_rect;
|
|
/* The sentinel's "visible" region is only kept populated while it is the
|
|
topmost sibling (the scene's damage traversal walks children in reverse
|
|
z-order, and an opaque client surface above it would empty the shared
|
|
update region before the sentinel is visited). Raise it, so the reorder
|
|
traversal populates its full-screen visible region, then bump its size
|
|
to re-damage that whole region; restore the z-order immediately so the
|
|
always-enabled transparent rect never intercepts pointer hit-testing. */
|
|
wlr_scene_node_raise_to_top(&r->node);
|
|
wlr_scene_rect_set_size(r, r->width, r->height - 1);
|
|
wlr_scene_rect_set_size(r, r->width, r->height);
|
|
wlr_scene_node_lower_to_bottom(&r->node);
|
|
}
|
|
|
|
static void update_overlay_visible(bool alt) {
|
|
if (alt == host.overlay_visible) return;
|
|
host.overlay_visible = alt;
|
|
if (server.standalone)
|
|
standalone_overlay_damage();
|
|
kick_request_redraw();
|
|
}
|
|
|
|
/* handle_new_output() forces software cursors on every standalone output, so
|
|
the pointer is always composited into our frame by the scene renderer --
|
|
which only repaints where the frame was damaged. Cursor motion damages the
|
|
*output*, not the scene, so force whole-output damage on every move or the
|
|
pointer would never be painted. */
|
|
static void standalone_cursor_motion(struct wl_listener *listener, void *data) {
|
|
(void)listener; (void)data;
|
|
if (server.standalone)
|
|
standalone_overlay_damage();
|
|
}
|
|
|
|
static struct wl_listener standalone_cursor_motion_listener = {
|
|
.notify = standalone_cursor_motion,
|
|
};
|
|
|
|
/* Is the configured tab-management prefix (Alt, or Super/Mod4 with
|
|
--modifier super) currently pressed? Uses the xkb modifier index when a
|
|
keymap is compiled; with no keymap (xkb init failed) it falls back to the
|
|
raw wl_keyboard modifier bit: mod1/alt = 1<<3, mod4/super = 1<<6. */
|
|
static bool prefix_active(uint32_t mods_depressed) {
|
|
if (server.xkb_state)
|
|
return xkb_state_mod_index_is_active(server.xkb_state,
|
|
server.xkb_prefix_mod, XKB_STATE_MODS_EFFECTIVE);
|
|
return (mods_depressed & (tab_prefix == PREFIX_SUPER ? (1u << 6)
|
|
: (1u << 3))) != 0;
|
|
}
|
|
|
|
/* Shared key handling for both the host keyboard and native input devices:
|
|
tab-switch bindings + overlay visibility, with the caller forwarding the
|
|
key to the seat afterwards. `mods_depressed` uses the wl_keyboard modifier
|
|
layout (shift=1<<0, ctrl=1<<2, alt=1<<3). Returns true when the key was
|
|
consumed by a binding and must not be forwarded to the seat. */
|
|
static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
|
|
uint32_t mods_depressed)
|
|
{
|
|
(void)time;
|
|
if (server.xkb_state)
|
|
xkb_state_update_key(server.xkb_state, key + 8,
|
|
state == WL_KEYBOARD_KEY_STATE_PRESSED
|
|
? XKB_KEY_DOWN : XKB_KEY_UP);
|
|
xkb_keysym_t sym = server.xkb_state
|
|
? xkb_state_key_get_one_sym(server.xkb_state, key + 8)
|
|
: XKB_KEY_NoSymbol;
|
|
/* With no compiled keymap (xkb init failed) fall back to the raw
|
|
evdev keycodes / modifier bits for the hotkey matching below. */
|
|
bool alt = prefix_active(mods_depressed);
|
|
if (alt != host.overlay_visible)
|
|
update_overlay_visible(alt);
|
|
char symbuf[32];
|
|
xkb_keysym_get_name(sym, symbuf, sizeof(symbuf));
|
|
kick_log(KICK_LOG_DEBUG,
|
|
"[kbd] key=%u state=%u sym=%s ctrl=%d alt=%d repeat_key=%u\n",
|
|
key, state, sym ? symbuf : "-",
|
|
(mods_depressed & (1u << 2)) != 0, alt, host.repeat_key);
|
|
/* Ctrl+Alt+F1..F12 switch virtual terminals in standalone mode. wlroots
|
|
has no built-in handler; the compositor must notice the key and ask the
|
|
session (libseat/logind) to activate another VT. Both the press and
|
|
release are consumed so the focused client never sees the chord. */
|
|
if (server.standalone && server.session &&
|
|
sym >= XKB_KEY_XF86Switch_VT_1 &&
|
|
sym <= XKB_KEY_XF86Switch_VT_12) {
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
|
unsigned vt = sym - XKB_KEY_XF86Switch_VT_1 + 1;
|
|
kick_log(KICK_LOG_INFO,
|
|
"kick: vt switch -> %u\n", vt);
|
|
wlr_session_change_vt(server.session, vt);
|
|
}
|
|
return true;
|
|
}
|
|
/* Function keys (F9/F10/F11) are not grabbed: they pass through to the
|
|
focused client, which may have its own bindings (e.g. a browser's F11
|
|
fullscreen request, which handle_request_fullscreen mirrors onto the
|
|
host window). Frame dumps are available via SIGUSR1 instead. */
|
|
/* Alt+Up / Alt+Down cycle tabs. Grabbed so the focused client never
|
|
sees them. 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.
|
|
Plain arrow keys (not Fn+arrow) are used because many laptop firmware
|
|
swallow Alt+Fn+PageUp (the Fn key reports as XF86WakeUp there), which
|
|
makes Alt+PageUp/PageDown unreliable. */
|
|
bool prev, next;
|
|
if (server.xkb_state) {
|
|
prev = sym == XKB_KEY_Up;
|
|
next = sym == XKB_KEY_Down;
|
|
} else {
|
|
prev = key == 103;
|
|
next = key == 108;
|
|
}
|
|
/* Alt+Shift+Up / Alt+Shift+Down move the active tab within the overlay
|
|
list (wrapping at the ends), without switching the active surface.
|
|
Checked before the plain Alt+arrow cycle below so Shift always means
|
|
"reorder, don't switch"; hold to keep moving via kbd_repeat_start.
|
|
Grabbed so the focused client never sees the chord (a terminal would
|
|
otherwise act on Shift+arrow). */
|
|
bool shift = (mods_depressed & (1u << 0)) != 0;
|
|
if (alt && shift && (prev || next)) {
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
|
if (host.repeat_key != key) {
|
|
int dir = next ? 1 : -1;
|
|
reorder_active_tab(dir);
|
|
kbd_repeat_start(key, dir, REPEAT_REORDER);
|
|
}
|
|
} else {
|
|
kbd_repeat_cancel();
|
|
}
|
|
return true;
|
|
}
|
|
if (alt && (prev || next)) {
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
|
if (host.repeat_key != key) {
|
|
int dir = next ? 1 : -1;
|
|
cycle_tab(dir);
|
|
kbd_repeat_start(key, dir, REPEAT_CYCLE);
|
|
}
|
|
} else {
|
|
kbd_repeat_cancel();
|
|
}
|
|
return true;
|
|
}
|
|
/* Alt+Left / Alt+Right rotate the active tab's stacked windows: Right
|
|
steps forward to the next window, Left steps backward to the previous
|
|
one, wrapping at the ends. Grabbed (consumed) so the focused client
|
|
never sees the chord -- a terminal would otherwise act on Left/Right.
|
|
When the tab has no stacked windows the chord is still consumed as a
|
|
no-op so Alt+arrow never leaks to the client. */
|
|
{
|
|
bool left = server.xkb_state ? (sym == XKB_KEY_Left) : (key == 105);
|
|
bool right = server.xkb_state ? (sym == XKB_KEY_Right) : (key == 106);
|
|
if (alt && !shift && (left || right)) {
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
|
if (host.repeat_key != key) {
|
|
if (server.active_app &&
|
|
!wl_list_empty(&server.active_app->group_members)) {
|
|
int dir = right ? 1 : -1;
|
|
cycle_group_members(server.active_app, dir);
|
|
kbd_repeat_start(key, dir, REPEAT_SUBCYCLE);
|
|
}
|
|
}
|
|
} else {
|
|
kbd_repeat_cancel();
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
/* prefix+Delete closes the active window (or the whole tab when it is the
|
|
only window). Grabbed so the focused client never sees the key; a latch
|
|
drops host-synthesized repeats so one hold can't fire it per repeat.
|
|
When the whole tab closes, the active surface moves to the right
|
|
neighbor inside close_tab(), so the tab is gone this frame even before
|
|
the client process exits. */
|
|
{ bool is_delete = server.xkb_state
|
|
? (sym == XKB_KEY_Delete)
|
|
: key == 111;
|
|
if (alt && is_delete) {
|
|
kbd_repeat_cancel();
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
|
if (!delete_key_held) {
|
|
delete_key_held = true;
|
|
close_active_window();
|
|
}
|
|
} else {
|
|
delete_key_held = false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
/* Alt+Shift+T opens a new terminal tab ($TERMINAL, default alacritty).
|
|
The tab is inserted right after the current one and becomes active when
|
|
it maps. Grabbed so the focused client never sees the chord; a latch
|
|
drops host-synthesized repeats so one hold can't open a terminal per
|
|
repeat. */
|
|
{
|
|
bool is_t = server.xkb_state
|
|
? (sym == XKB_KEY_t || sym == XKB_KEY_T)
|
|
: key == 20;
|
|
if (alt && shift && is_t) {
|
|
kbd_repeat_cancel();
|
|
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
|
|
if (!term_key_held) {
|
|
term_key_held = true;
|
|
if (!cmd_in_path(term_cmd)) {
|
|
kick_log(KICK_LOG_INFO,
|
|
"kick: '%s' not found in PATH; "
|
|
"no terminal opened (set $TERMINAL)\n",
|
|
term_cmd);
|
|
} else {
|
|
add_tab(term_cmd);
|
|
}
|
|
}
|
|
} else {
|
|
term_key_held = false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
/* any other key activity stops the tab-switch repeat */
|
|
kbd_repeat_cancel();
|
|
return false;
|
|
}
|
|
|
|
/* Shared modifier handling for the host keyboard and native devices. */
|
|
static void process_modifiers(uint32_t mods_depressed, uint32_t mods_latched,
|
|
uint32_t mods_locked, uint32_t group)
|
|
{
|
|
host.host_mods_depressed = mods_depressed;
|
|
if (server.xkb_state)
|
|
xkb_state_update_mask(server.xkb_state, mods_depressed,
|
|
mods_latched, mods_locked, group, group, group);
|
|
bool alt = prefix_active(mods_depressed);
|
|
if (alt != host.overlay_visible)
|
|
update_overlay_visible(alt);
|
|
if (host.repeat_key && !alt)
|
|
kbd_repeat_cancel();
|
|
}
|
|
|
|
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)serial;
|
|
if (synth_input_only && kbd)
|
|
return;
|
|
(void)kbd;
|
|
process_modifiers(mods_depressed, mods_latched, mods_locked, group);
|
|
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,
|
|
};
|
|
|
|
/* ============ Standalone native input devices ============
|
|
In standalone mode real keyboard/pointer devices come from the backend
|
|
(libinput). Their events are routed through the same hotkey logic and are
|
|
then forwarded into the shared seat keyboard exactly like host input.
|
|
|
|
The keyboard listeners are per-device (one struct standalone_kbdev per
|
|
libinput keyboard): ending a single global slot makes modifier state come
|
|
from whichever device attached last (often an HDMI-CEC or consumer-control
|
|
node), so Shift held on the real keyboard never reached the seat. */
|
|
|
|
static void standalone_kbd_key(struct wl_listener *listener, void *data) {
|
|
struct standalone_kbdev *kd = wl_container_of(listener, kd, key);
|
|
struct wlr_keyboard_key_event *ev = data;
|
|
uint32_t mods = wlr_keyboard_get_modifiers(kd->keyboard);
|
|
if (!process_key_event(ev->time_msec, ev->keycode, ev->state, mods)) {
|
|
wlr_seat_set_keyboard(server.seat, kd->keyboard);
|
|
wlr_seat_keyboard_notify_key(server.seat, ev->time_msec, ev->keycode,
|
|
ev->state);
|
|
}
|
|
}
|
|
|
|
static void standalone_kbd_modifiers(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct standalone_kbdev *kd = wl_container_of(listener, kd, modifiers);
|
|
struct wlr_keyboard *kb = kd->keyboard;
|
|
process_modifiers(kb->modifiers.depressed, kb->modifiers.latched,
|
|
kb->modifiers.locked, kb->modifiers.group);
|
|
wlr_seat_set_keyboard(server.seat, kb);
|
|
wlr_seat_keyboard_notify_modifiers(server.seat, &kb->modifiers);
|
|
}
|
|
|
|
/* The device is going away (libinput suspends all devices when the session
|
|
loses its VT). Drop our listeners so wlr_keyboard_finish's assert that
|
|
every event list is empty holds, then free the struct. wlroots emits the
|
|
input device's destroy signal from within wlr_keyboard_finish(), so this
|
|
runs before the assert. */
|
|
static void standalone_kbd_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct standalone_kbdev *kd = wl_container_of(listener, kd, destroy);
|
|
wl_list_remove(&kd->destroy.link);
|
|
wl_list_remove(&kd->key.link);
|
|
wl_list_remove(&kd->modifiers.link);
|
|
wl_list_remove(&kd->link);
|
|
free(kd);
|
|
}
|
|
|
|
/* Forward the wlr_cursor's position to the seat as the effect/click point.
|
|
wlroots is the single source of truth for where the pointer is: it clamps
|
|
cursor->x/y against the output layout (wlr_cursor_warp_closest), so the
|
|
visible arrow and the coordinates forwarded here stay in the same space,
|
|
even when a requested --screen-mode mode differs from the geometry the
|
|
output layout derives from the committed state. Never hand-clamp a second
|
|
copy. */
|
|
static void standalone_forward_pointer(uint32_t time) {
|
|
struct wlr_output *output = server.output;
|
|
if (!server.cursor || !output) return;
|
|
double x = server.cursor->x;
|
|
double y = server.cursor->y;
|
|
wlr_output_layout_output_coords(server.output_layout, output, &x, &y);
|
|
host_ptr_motion(time, x, y);
|
|
}
|
|
|
|
/* Shared standalone pointer-motion handling, used by both the libinput device
|
|
listeners and the stdin ctl feed (which has no device, hence NULL). The
|
|
wlr_cursor never moves on its own in wlroots 0.19 -- the device wiring only
|
|
re-emits motion on cursor->events.motion -- so every relative delta must be
|
|
turned into wlr_cursor_move() or the visible pointer stays pinned while the
|
|
seat (and therefore client hover) keeps moving. With a NULL device the move
|
|
falls back to the output layout's closest-point mapping. */
|
|
/* ---------- pointer constraints + relative pointer ---------- */
|
|
|
|
/* Forward a relative pointer delta to clients (zwp_relative_pointer_v1).
|
|
The manager scopes delivery to clients that bound it on the seat. */
|
|
static void send_relative_motion(uint32_t time_msec, double dx, double dy) {
|
|
if (!server.relative_pointer_mgr || !server.seat) return;
|
|
kick_log(KICK_LOG_INFO, "[relative] motion dx=%d dy=%d\n",
|
|
(int)dx, (int)dy);
|
|
wlr_relative_pointer_manager_v1_send_relative_motion(
|
|
server.relative_pointer_mgr, server.seat,
|
|
(uint64_t)time_msec * 1000, dx, dy, dx, dy);
|
|
}
|
|
|
|
/* Find the scene origin (in scene/layout coordinates) of a surface's buffer
|
|
node, so a constraint region in surface-local space can be mapped to the
|
|
output. Surfaces live under per-app scene trees, so the walk is
|
|
recursive. */
|
|
static bool scene_surface_origin_in(struct wlr_scene_tree *tree,
|
|
struct wlr_surface *surface,
|
|
double *ox, double *oy) {
|
|
struct wlr_scene_node *node;
|
|
wl_list_for_each(node, &tree->children, link) {
|
|
if (node->type == WLR_SCENE_NODE_BUFFER) {
|
|
struct wlr_scene_buffer *sb = wlr_scene_buffer_from_node(node);
|
|
struct wlr_scene_surface *ss = wlr_scene_surface_try_from_buffer(sb);
|
|
if (ss && ss->surface == surface) {
|
|
int lx = 0, ly = 0;
|
|
if (!wlr_scene_node_coords(node, &lx, &ly)) return false;
|
|
*ox = (double)lx;
|
|
*oy = (double)ly;
|
|
return true;
|
|
}
|
|
} else if (node->type == WLR_SCENE_NODE_TREE) {
|
|
if (scene_surface_origin_in(wlr_scene_tree_from_node(node),
|
|
surface, ox, oy))
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool scene_surface_origin(struct wlr_surface *surface,
|
|
double *ox, double *oy) {
|
|
return scene_surface_origin_in(&server.scene->tree, surface, ox, oy);
|
|
}
|
|
|
|
static void constraint_deactivate(void) {
|
|
if (!server.active_constraint) return;
|
|
wlr_pointer_constraint_v1_send_deactivated(server.active_constraint);
|
|
wl_list_remove(&server.constraint_destroy.link);
|
|
server.active_constraint = NULL;
|
|
}
|
|
|
|
static void handle_constraint_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct kick_server *s = wl_container_of(listener, s, constraint_destroy);
|
|
s->active_constraint = NULL;
|
|
}
|
|
|
|
static void constraint_activate(struct wlr_surface *surface) {
|
|
if (!server.pointer_constraints || !surface) return;
|
|
struct wlr_pointer_constraint_v1 *c = wlr_pointer_constraints_v1_constraint_for_surface(
|
|
server.pointer_constraints, surface, server.seat);
|
|
if (!c) return;
|
|
server.active_constraint = c;
|
|
if (!scene_surface_origin(surface, &server.constraint_ox, &server.constraint_oy)) {
|
|
server.constraint_ox = 0.0;
|
|
server.constraint_oy = 0.0;
|
|
}
|
|
server.lock_x = server.last_ptr_x;
|
|
server.lock_y = server.last_ptr_y;
|
|
server.constraint_destroy.notify = handle_constraint_destroy;
|
|
wl_signal_add(&c->events.destroy, &server.constraint_destroy);
|
|
wlr_pointer_constraint_v1_send_activated(c);
|
|
if (c->type == WLR_POINTER_CONSTRAINT_V1_LOCKED)
|
|
kick_log(KICK_LOG_INFO, "[constraint] locked on surface\n");
|
|
else
|
|
kick_log(KICK_LOG_INFO, "[constraint] confined on surface\n");
|
|
}
|
|
|
|
/* Focus change is the only place the pointer's surface can change, so it is
|
|
the natural point to swap the active constraint. */
|
|
static void handle_seat_pointer_focus(struct wl_listener *listener, void *data) {
|
|
(void)listener;
|
|
struct wlr_seat_pointer_focus_change_event *ev = data;
|
|
constraint_deactivate();
|
|
if (ev->new_surface)
|
|
constraint_activate(ev->new_surface);
|
|
}
|
|
|
|
/* Clamp an absolute pointer position to whatever the active constraint
|
|
allows, in place. LOCKED pins to the lock position; CONFINED keeps the
|
|
pointer inside the constraint region (empty region = whole surface). */
|
|
static void constraint_filter_abs(double *x, double *y) {
|
|
struct wlr_pointer_constraint_v1 *c = server.active_constraint;
|
|
if (!c) return;
|
|
struct wlr_surface *surface = c->surface;
|
|
if (c->type == WLR_POINTER_CONSTRAINT_V1_LOCKED) {
|
|
*x = server.lock_x;
|
|
*y = server.lock_y;
|
|
return;
|
|
}
|
|
int w = surface->current.width;
|
|
int h = surface->current.height;
|
|
if (w <= 0 || h <= 0) return;
|
|
int x1 = 0, y1 = 0, x2 = w, y2 = h;
|
|
if (pixman_region32_not_empty(&c->region)) {
|
|
pixman_box32_t *ext = pixman_region32_extents(&c->region);
|
|
x1 = ext->x1;
|
|
y1 = ext->y1;
|
|
x2 = ext->x2;
|
|
y2 = ext->y2;
|
|
if (x2 > w) x2 = w;
|
|
if (y2 > h) y2 = h;
|
|
}
|
|
if (x1 >= x2) x1 = x2 - 1;
|
|
if (y1 >= y2) y1 = y2 - 1;
|
|
double lx = *x - server.constraint_ox;
|
|
double ly = *y - server.constraint_oy;
|
|
if (lx < x1) lx = x1;
|
|
else if (lx >= x2) lx = x2 - 1;
|
|
if (ly < y1) ly = y1;
|
|
else if (ly >= y2) ly = y2 - 1;
|
|
*x = server.constraint_ox + lx;
|
|
*y = server.constraint_oy + ly;
|
|
}
|
|
|
|
static void native_ptr_motion(uint32_t time, struct wlr_input_device *dev,
|
|
double dx, double dy) {
|
|
if (!server.cursor) return;
|
|
send_relative_motion(time, dx, dy);
|
|
wlr_cursor_move(server.cursor, dev, dx, dy);
|
|
/* Keep the visible arrow inside the active constraint's region (or
|
|
pinned, when locked). wlr_cursor is the source of truth for the
|
|
position, so clamp it rather than forwarding a second copy. */
|
|
if (server.active_constraint) {
|
|
double x = server.cursor->x, y = server.cursor->y;
|
|
constraint_filter_abs(&x, &y);
|
|
/* Re-warp through wlroots so the output cursor (the rendered arrow)
|
|
follows: writing cursor->x/y directly would leave it behind. */
|
|
if (x != server.cursor->x || y != server.cursor->y)
|
|
wlr_cursor_warp(server.cursor, NULL, x, y);
|
|
}
|
|
standalone_forward_pointer(time);
|
|
}
|
|
|
|
static void native_ptr_abs(uint32_t time, struct wlr_input_device *dev,
|
|
double x, double y) {
|
|
if (!server.cursor) return;
|
|
wlr_cursor_warp_absolute(server.cursor, dev, x, y);
|
|
standalone_forward_pointer(time);
|
|
}
|
|
|
|
static void standalone_ptr_motion_event(struct wl_listener *listener, void *data) {
|
|
struct standalone_ptrdev *pd = wl_container_of(listener, pd, motion);
|
|
struct wlr_pointer_motion_event *ev = data;
|
|
native_ptr_motion(ev->time_msec,
|
|
(struct wlr_input_device *)&pd->pointer->base,
|
|
ev->delta_x, ev->delta_y);
|
|
}
|
|
|
|
static void standalone_ptr_abs_event(struct wl_listener *listener, void *data) {
|
|
struct standalone_ptrdev *pd = wl_container_of(listener, pd, motion_absolute);
|
|
struct wlr_pointer_motion_absolute_event *ev = data;
|
|
native_ptr_abs(ev->time_msec,
|
|
(struct wlr_input_device *)&pd->pointer->base,
|
|
ev->x, ev->y);
|
|
}
|
|
|
|
static void standalone_ptr_button_event(struct wl_listener *listener, void *data) {
|
|
(void)listener;
|
|
struct wlr_pointer_button_event *ev = data;
|
|
if (!server.seat) return;
|
|
wlr_seat_pointer_notify_button(server.seat, ev->time_msec, ev->button,
|
|
ev->state);
|
|
wlr_seat_pointer_notify_frame(server.seat);
|
|
}
|
|
|
|
static void standalone_ptr_axis_event(struct wl_listener *listener, void *data) {
|
|
(void)listener;
|
|
struct wlr_pointer_axis_event *ev = data;
|
|
if (!server.seat) return;
|
|
wlr_seat_pointer_notify_axis(server.seat, ev->time_msec, ev->orientation,
|
|
ev->delta, ev->delta_discrete, ev->source,
|
|
WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL);
|
|
wlr_seat_pointer_notify_frame(server.seat);
|
|
}
|
|
|
|
static void standalone_ptr_frame_event(struct wl_listener *listener, void *data) {
|
|
(void)listener; (void)data;
|
|
}
|
|
|
|
/* The device is going away (libinput suspends all devices when the session
|
|
loses its VT). Drop our listeners so wlr_pointer_finish's assert that
|
|
every event list is empty holds, then free the struct. */
|
|
static void standalone_ptr_destroy(struct wl_listener *listener, void *data) {
|
|
(void)data;
|
|
struct standalone_ptrdev *pd = wl_container_of(listener, pd, destroy);
|
|
wl_list_remove(&pd->destroy.link);
|
|
wl_list_remove(&pd->motion.link);
|
|
wl_list_remove(&pd->motion_absolute.link);
|
|
wl_list_remove(&pd->button.link);
|
|
wl_list_remove(&pd->axis.link);
|
|
wl_list_remove(&pd->frame.link);
|
|
wl_list_remove(&pd->link);
|
|
free(pd);
|
|
}
|
|
|
|
static void handle_new_input(struct wl_listener *listener, void *data) {
|
|
(void)listener;
|
|
struct wlr_input_device *device = data;
|
|
|
|
switch (device->type) {
|
|
case WLR_INPUT_DEVICE_KEYBOARD: {
|
|
struct wlr_keyboard *kb = wlr_keyboard_from_input_device(device);
|
|
/* Without a keymap the device never emits modifier events; give it
|
|
ours so both the hotkeys and the seat work. */
|
|
if (server.xkb_keymap)
|
|
wlr_keyboard_set_keymap(kb, server.xkb_keymap);
|
|
struct standalone_kbdev *kd = calloc(1, sizeof(*kd));
|
|
if (!kd) break;
|
|
kd->keyboard = kb;
|
|
kd->key.notify = standalone_kbd_key;
|
|
wl_signal_add(&kb->events.key, &kd->key);
|
|
kd->modifiers.notify = standalone_kbd_modifiers;
|
|
wl_signal_add(&kb->events.modifiers, &kd->modifiers);
|
|
kd->destroy.notify = standalone_kbd_destroy;
|
|
wl_signal_add(&kb->base.events.destroy, &kd->destroy);
|
|
wl_list_insert(server.kbdevs.prev, &kd->link);
|
|
kick_log(KICK_LOG_INFO, "standalone: keyboard '%s'\n", device->name);
|
|
break;
|
|
}
|
|
case WLR_INPUT_DEVICE_POINTER: {
|
|
struct wlr_pointer *ptr = wlr_pointer_from_input_device(device);
|
|
if (server.cursor)
|
|
wlr_cursor_attach_input_device(server.cursor, device);
|
|
struct standalone_ptrdev *pd = calloc(1, sizeof(*pd));
|
|
if (!pd) break;
|
|
pd->pointer = ptr;
|
|
pd->motion.notify = standalone_ptr_motion_event;
|
|
wl_signal_add(&ptr->events.motion, &pd->motion);
|
|
pd->motion_absolute.notify = standalone_ptr_abs_event;
|
|
wl_signal_add(&ptr->events.motion_absolute, &pd->motion_absolute);
|
|
pd->button.notify = standalone_ptr_button_event;
|
|
wl_signal_add(&ptr->events.button, &pd->button);
|
|
pd->axis.notify = standalone_ptr_axis_event;
|
|
wl_signal_add(&ptr->events.axis, &pd->axis);
|
|
pd->frame.notify = standalone_ptr_frame_event;
|
|
wl_signal_add(&ptr->events.frame, &pd->frame);
|
|
pd->destroy.notify = standalone_ptr_destroy;
|
|
wl_signal_add(&ptr->base.events.destroy, &pd->destroy);
|
|
wl_list_insert(server.ptrdevs.prev, &pd->link);
|
|
kick_log(KICK_LOG_INFO, "standalone: pointer '%s'\n", device->name);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* ============ 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_CMD_WARP 3
|
|
#define CTL_CMD_PTR_MOTION 4
|
|
|
|
#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;
|
|
case CTL_CMD_WARP:
|
|
if (len < 9) return;
|
|
memcpy(u32, msg + 1, 8);
|
|
ctl_time += 1;
|
|
/* Absolute standalone-output-pixel position, so tests can place the
|
|
visible cursor (wlr_cursor_warp with a NULL device). Warp doesn't
|
|
emit a motion event, so damage the scene here like motion would, and
|
|
forward the new position to the seat so the effect point stays where
|
|
the arrow lands. Nested mode has no wlr_cursor: feed the position
|
|
straight into the seat instead, so the ctl feed stays usable in both
|
|
modes (e.g. to give a client a pointer enter so it can request a
|
|
cursor shape). */
|
|
if (server.standalone) {
|
|
double wx = (double)u32[0], wy = (double)u32[1];
|
|
constraint_filter_abs(&wx, &wy);
|
|
wlr_cursor_warp(server.cursor, NULL, wx, wy);
|
|
standalone_forward_pointer(ctl_time);
|
|
standalone_overlay_damage();
|
|
} else {
|
|
host_ptr_motion(ctl_time, (double)u32[0], (double)u32[1]);
|
|
}
|
|
break;
|
|
case CTL_CMD_PTR_MOTION:
|
|
if (len < 9) return;
|
|
memcpy(u32, msg + 1, 8);
|
|
ctl_time += 1;
|
|
/* Relative delta through the same path a libinput pointer device
|
|
takes (native_ptr_motion -> wlr_cursor_move). With no device there
|
|
is no cursor->events.motion, so damage the scene explicitly. */
|
|
native_ptr_motion(ctl_time, NULL, (double)u32[0], (double)u32[1]);
|
|
if (server.standalone)
|
|
standalone_overlay_damage();
|
|
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;
|
|
}
|
|
|
|
/* ---------- 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;
|
|
server.last_ptr_x = sx;
|
|
server.last_ptr_y = sy;
|
|
constraint_filter_abs(&sx, &sy);
|
|
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);
|
|
/* Pointer over no nested surface (black tab): drop any client shape. */
|
|
apply_cursor_shape(WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT);
|
|
}
|
|
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)surface;
|
|
host.ptr_serial = serial;
|
|
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;
|
|
double nx = wl_fixed_to_double(sx), ny = wl_fixed_to_double(sy);
|
|
/* Nested mode has no wlr_cursor: derive the relative delta from the
|
|
consecutive absolute host positions. (Standalone deltas are sent from
|
|
native_ptr_motion instead.) */
|
|
send_relative_motion(time, nx - server.last_ptr_x, ny - server.last_ptr_y);
|
|
host_ptr_motion(time, nx, ny);
|
|
}
|
|
|
|
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;
|
|
int32_t discrete = axis < 2 ? host.axis_discrete[axis] : 0;
|
|
host.axis_discrete[axis] = 0;
|
|
wlr_seat_pointer_notify_axis(server.seat, time, axis,
|
|
wl_fixed_to_double(value), discrete,
|
|
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)
|
|
{
|
|
(void)data; (void)p;
|
|
if (axis < 2)
|
|
host.axis_discrete[axis] += discrete;
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
/* When the host compositor (labwc on a VT round-trip) removes a seat
|
|
capability, every object created from that seat -- our wl_keyboard /
|
|
wl_pointer -- is invalidated, yet the client keeps running silently with
|
|
no input ever arriving again. Re-create the objects as capabilities are
|
|
re-gained; the compositor re-sends keymap/modifiers/enter on the fresh
|
|
wl_keyboard, and host_kbd_enter() re-focuses our nested seat. */
|
|
static void host_seat_capabilities(void *data, struct wl_seat *seat,
|
|
uint32_t capabilities)
|
|
{
|
|
(void)data; (void)seat;
|
|
kick_log(KICK_LOG_DEBUG, "[seat] capabilities=0x%x\n", capabilities);
|
|
const bool have_kbd = (capabilities & WL_SEAT_CAPABILITY_KEYBOARD) != 0;
|
|
const bool have_ptr = (capabilities & WL_SEAT_CAPABILITY_POINTER) != 0;
|
|
if (have_kbd && !host.keyboard) {
|
|
host.keyboard = wl_seat_get_keyboard(host.seat);
|
|
if (host.keyboard) {
|
|
kick_log(KICK_LOG_DEBUG, "[seat] re-gained keyboard\n");
|
|
wl_keyboard_add_listener(host.keyboard, &host_kbd_listener, NULL);
|
|
}
|
|
} else if (!have_kbd && host.keyboard) {
|
|
wl_keyboard_release(host.keyboard);
|
|
host.keyboard = NULL;
|
|
kick_log(KICK_LOG_DEBUG, "[seat] keyboard capability lost\n");
|
|
}
|
|
if (have_ptr && !host.pointer) {
|
|
host.pointer = wl_seat_get_pointer(host.seat);
|
|
if (host.pointer) {
|
|
kick_log(KICK_LOG_DEBUG, "[seat] re-gained pointer\n");
|
|
wl_pointer_add_listener(host.pointer, &host_ptr_listener, NULL);
|
|
}
|
|
} else if (!have_ptr && host.pointer) {
|
|
host.shape_dev = NULL; /* the shape device goes inert too */
|
|
wl_pointer_release(host.pointer);
|
|
host.pointer = NULL;
|
|
kick_log(KICK_LOG_DEBUG, "[seat] pointer capability lost\n");
|
|
}
|
|
/* The cursor-shape device rides on the host pointer: (re)create it once
|
|
the pointer is (re)available. */
|
|
if (host.pointer)
|
|
host_cursor_shape_get_pointer();
|
|
}
|
|
|
|
/* ===================== 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, "kick: 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);
|
|
host.registry_synced = true;
|
|
|
|
if (!host.compositor || !host.wm_base) {
|
|
fprintf(stderr, "kick: host lacks compositor/xdg_wm_base\n");
|
|
return false;
|
|
}
|
|
|
|
host.surface = wl_compositor_create_surface(host.compositor);
|
|
host.libdecor = libdecor_new(host.display, &host_libdecor_iface);
|
|
if (!host.libdecor) {
|
|
fprintf(stderr, "kick: libdecor failed to initialise\n");
|
|
return false;
|
|
}
|
|
host.frame = libdecor_decorate(host.libdecor, host.surface,
|
|
&host_frame_iface, NULL);
|
|
if (!host.frame) {
|
|
fprintf(stderr, "kick: libdecor_decorate failed\n");
|
|
return false;
|
|
}
|
|
libdecor_frame_set_title(host.frame, "kick");
|
|
libdecor_frame_set_app_id(host.frame, "kick");
|
|
/* Ask libdecor for a frame around our content. It negotiates xdg
|
|
decorations with the host: a server-side title bar when the host offers
|
|
one (labwc, sway), its own client-side title bar (drawing in a
|
|
subsurface above our content) otherwise -- GNOME/Mutter forces CSD, so
|
|
the window becomes draggable and resizable through that bar. */
|
|
libdecor_frame_map(host.frame);
|
|
/* libdecor pongs pings on its own wm_base binding; ours is separate, so
|
|
keep ponging the ones addressed to it or the host declares us hung. */
|
|
xdg_wm_base_add_listener(host.wm_base, &host_xdg_wm_listener, NULL);
|
|
wl_surface_commit(host.surface);
|
|
wl_display_roundtrip(host.display);
|
|
libdecor_dispatch(host.libdecor, 0);
|
|
|
|
/* Host keyboard/pointer are created by the seat capabilities handler
|
|
during the registry round-trip above; nothing more to fetch here. */
|
|
bridge_init();
|
|
|
|
/* Try EGL on the host wayland platform */
|
|
pfn_eglGetPlatformDisplayEXT = (PFNEGLGETPLATFORMDISPLAYEXTPROC)
|
|
eglGetProcAddress("eglGetPlatformDisplayEXT");
|
|
if (!pfn_eglGetPlatformDisplayEXT) {
|
|
fprintf(stderr, "kick: 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, "kick: EGL display failed; "
|
|
"falling back to software rendering\n");
|
|
host.egl_ok = false;
|
|
return true;
|
|
}
|
|
if (!eglInitialize(host.egl_display, NULL, NULL)) {
|
|
fprintf(stderr, "kick: 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, "kick: 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, "kick: 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, "kick: 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, "kick: 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, "kick: GL setup failed; software fallback\n");
|
|
host.egl_ok = false;
|
|
return true;
|
|
}
|
|
overlay_font_init();
|
|
|
|
host.egl_ok = true;
|
|
kick_log(KICK_LOG_INFO, "kick: EGL (GPU) rendering enabled\n");
|
|
return true;
|
|
}
|
|
|
|
/* ===================== Scene buffer -> host blit ===================== */
|
|
|
|
static bool import_scene_buffer(struct wlr_buffer *buf, GLuint tex) {
|
|
struct wlr_dmabuf_attributes attrs;
|
|
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) {
|
|
kick_log(KICK_LOG_DEBUG, "[import] eglCreateImageKHR failed "
|
|
"(format 0x%x modifier 0x%llx)\n", attrs.format,
|
|
(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;
|
|
}
|
|
|
|
/* 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)) {
|
|
kick_log(KICK_LOG_DEBUG, "[import] buffer is neither dmabuf nor "
|
|
"CPU-readable\n");
|
|
return false;
|
|
}
|
|
glBindTexture(GL_TEXTURE_2D, tex);
|
|
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:
|
|
kick_log(KICK_LOG_DEBUG, "[import] unsupported CPU fallback format "
|
|
"0x%x\n", fmt);
|
|
wlr_buffer_end_data_ptr_access(buf);
|
|
return false;
|
|
}
|
|
wlr_buffer_end_data_ptr_access(buf);
|
|
return true;
|
|
}
|
|
|
|
/* 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);
|
|
}
|
|
|
|
static void blit_scene_buffer(struct wlr_buffer *buf) {
|
|
int w = host.width, h = host.height;
|
|
glViewport(0, 0, w, h);
|
|
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
|
|
bool imported = buf && import_scene_buffer(buf, host.import_tex);
|
|
if (imported) {
|
|
static const GLfloat white[4] = { 1, 1, 1, 1 };
|
|
glEnable(GL_BLEND);
|
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
|
draw_texture_rect(host.import_tex, buf->width, buf->height,
|
|
0, 0, buf->width, buf->height, 0, 0,
|
|
buf->width, buf->height, w, h, white);
|
|
draw_tab_overlay();
|
|
glDisable(GL_BLEND);
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
|
|
/* Standalone (native-output) frame: composite the scene into our swapchain,
|
|
then draw the Alt overlay as a second pass on the same buffer from wlr
|
|
textures. There is no host EGL surface to blit to and no host frame-call
|
|
pacing; the native output's own commit drives presentation. */
|
|
static void render_standalone(void) {
|
|
if (!server.scene_output || !server.swapchain) return;
|
|
|
|
struct wlr_output_state state;
|
|
wlr_output_state_init(&state);
|
|
apply_pending_output_resize(&state);
|
|
struct wlr_scene_output_state_options options = {
|
|
.swapchain = server.swapchain,
|
|
};
|
|
bool rendered = wlr_scene_output_build_state(server.scene_output, &state,
|
|
&options);
|
|
if (!rendered || !state.buffer) {
|
|
kick_log(KICK_LOG_DEBUG, "[render] build_state failed\n");
|
|
wlr_output_state_finish(&state);
|
|
goto frame_done;
|
|
}
|
|
|
|
if (dump_scene_req) {
|
|
dump_scene_req = false;
|
|
dump_scene_buffer(state.buffer);
|
|
}
|
|
|
|
if (host.overlay_visible) {
|
|
struct wlr_render_pass *pass = wlr_renderer_begin_buffer_pass(
|
|
server.renderer, state.buffer, NULL);
|
|
if (pass) {
|
|
draw_tab_overlay_pass(pass);
|
|
wlr_render_pass_submit(pass);
|
|
}
|
|
}
|
|
|
|
if (dump_host_req) {
|
|
dump_host_req = false;
|
|
dump_standalone_buffer(state.buffer);
|
|
}
|
|
|
|
if (!wlr_output_commit_state(server.output, &state))
|
|
kick_log(KICK_LOG_DEBUG, "[render] output commit failed\n");
|
|
|
|
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);
|
|
}
|
|
|
|
static void render_and_present(void) {
|
|
if (server.standalone) {
|
|
render_standalone();
|
|
return;
|
|
}
|
|
if (!host.egl_ok || !host.configured) return;
|
|
if (!server.scene_output || !server.swapchain) return;
|
|
|
|
struct wlr_output_state state;
|
|
wlr_output_state_init(&state);
|
|
apply_pending_output_resize(&state);
|
|
struct wlr_scene_output_state_options options = {
|
|
.swapchain = server.swapchain,
|
|
};
|
|
bool rendered = wlr_scene_output_build_state(server.scene_output, &state,
|
|
&options);
|
|
if (!rendered || !state.buffer) {
|
|
kick_log(KICK_LOG_DEBUG, "[render] build_state failed\n");
|
|
wlr_output_state_finish(&state);
|
|
goto frame_done;
|
|
}
|
|
|
|
/* [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). */
|
|
if (kick_log_level() >= KICK_LOG_DEBUG) {
|
|
struct wlr_output *out = server.output;
|
|
kick_log(KICK_LOG_DEBUG, "[diag] frame buffer %dx%d (output %dx%d)\n",
|
|
state.buffer->width, state.buffer->height,
|
|
out ? out->width : 0, out ? out->height : 0);
|
|
}
|
|
|
|
/* 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);
|
|
}
|
|
|
|
static int autodump = -1;
|
|
if (autodump < 0)
|
|
autodump = getenv("KICK_AUTODUMP") ? 1 : 0;
|
|
if (autodump > 0 && client_dumped && --autodump == 0) {
|
|
dump_scene_req = true;
|
|
dump_host_req = true;
|
|
/* The flags are consumed by the *next* render pass; under the
|
|
idle-stop gate that pass needs an explicit redraw request. */
|
|
kick_request_redraw();
|
|
}
|
|
|
|
blit_scene_buffer(state.buffer);
|
|
|
|
if (!wlr_output_commit_state(server.output, &state))
|
|
kick_log(KICK_LOG_DEBUG, "[render] output commit failed\n");
|
|
|
|
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 ===================== */
|
|
|
|
/* Does the first whitespace-delimited word of `cmd` resolve to an executable
|
|
on PATH? Mirrors how `sh -c <cmd>` resolves the first word, so this is a
|
|
cheap pre-launch "which" check. Returns 1 if found (or uncheckable, e.g.
|
|
a word too long or no PATH), 0 if the word is definitely missing. */
|
|
static int cmd_in_path(const char *cmd) {
|
|
while (*cmd == ' ' || *cmd == '\t')
|
|
cmd++;
|
|
const char *p = cmd;
|
|
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
|
|
p++;
|
|
if (p == cmd)
|
|
return 0;
|
|
char word[256];
|
|
size_t n = (size_t)(p - cmd);
|
|
if (n >= sizeof(word))
|
|
return 1; /* give up on absurdly long commands */
|
|
memcpy(word, cmd, n);
|
|
word[n] = '\0';
|
|
|
|
if (strchr(word, '/'))
|
|
return access(word, X_OK) == 0;
|
|
|
|
const char *path_env = getenv("PATH");
|
|
if (!path_env) path_env = "/usr/local/bin:/usr/bin:/bin";
|
|
char buf[512];
|
|
for (const char *d = path_env; d && *d; ) {
|
|
const char *colon = strchr(d, ':');
|
|
size_t len = colon ? (size_t)(colon - d) : strlen(d);
|
|
if (len == 0) {
|
|
len = 1;
|
|
buf[0] = '.';
|
|
} else {
|
|
if (len + 1 + n >= sizeof(buf)) {
|
|
if (colon) d = colon + 1; else d = NULL;
|
|
continue;
|
|
}
|
|
memcpy(buf, d, len);
|
|
buf[len] = '/';
|
|
}
|
|
memcpy(buf + len + 1, word, n);
|
|
buf[len + 1 + n] = '\0';
|
|
if (access(buf, X_OK) == 0)
|
|
return 1;
|
|
if (colon) d = colon + 1; else d = NULL;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
#if HAVE_XWAYLAND
|
|
/* Kick runs its own plain Xwayland (see the wlr_xwayland_create call site):
|
|
no -auth, so any local client connects without a magic cookie and nothing
|
|
is written to the session Xauthority. This is the normal wlroots default
|
|
and avoids the legacy X11 access-control dance entirely. */
|
|
#endif
|
|
|
|
static void launch_client(struct kick_app *app) {
|
|
pid_t pid = fork();
|
|
if (pid == 0) {
|
|
/* Own process group so we can kill the whole client tree on exit. */
|
|
setpgid(0, 0);
|
|
const char *xdg_runtime = getenv("XDG_RUNTIME_DIR");
|
|
if (!xdg_runtime) xdg_runtime = "/tmp";
|
|
setenv("WAYLAND_DISPLAY", kick_socket, 1);
|
|
setenv("XDG_RUNTIME_DIR", xdg_runtime, 1);
|
|
setenv("GDK_BACKEND", "wayland", 1);
|
|
setenv("QT_QPA_PLATFORM", "wayland", 1);
|
|
#if HAVE_XWAYLAND
|
|
/* X-only commands (xmessage, xeyes, legacy toolkits) connect through
|
|
our Xwayland. GDK/Qt stay on Wayland via the forced backends
|
|
above; SDL and friends prefer Wayland too, so DISPLAY only picks
|
|
up clients with no Wayland support. */
|
|
if (server.xwayland && server.xwayland->display_name) {
|
|
setenv("DISPLAY", server.xwayland->display_name, 1);
|
|
} else
|
|
unsetenv("DISPLAY");
|
|
#else
|
|
unsetenv("DISPLAY");
|
|
#endif
|
|
/* Unless debugging, don't let client chatter spill onto the launching
|
|
terminal; the client inherits our stdout/stderr. */
|
|
if (kick_log_level() < KICK_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);
|
|
}
|
|
}
|
|
execlp("/bin/sh", "sh", "-c", app->cmd, NULL);
|
|
_exit(127);
|
|
}
|
|
if (pid > 0) {
|
|
app->pid = pid;
|
|
kick_log(KICK_LOG_INFO, "[launch] app=%d pid=%d cmd=%s\n",
|
|
app_index(app), pid, app->cmd);
|
|
}
|
|
}
|
|
|
|
/* Fork every command-line command at startup, in command-line order. Each
|
|
slot keeps its position because handle_new_toplevel claims it by process
|
|
group (launch_client runs each command in its own setpgid'd group, whose
|
|
id equals app->pid), so the tab order mirrors the command line no matter
|
|
which client connects first. A command that never connects leaves its
|
|
tab black; a command whose process group exits without ever connecting is
|
|
swept away later (see sweep_dead_startup_groups). */
|
|
static void launch_all_cli(void) {
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *a = server.apps[i];
|
|
if (a->origin != ORIGIN_CLI) continue;
|
|
launch_client(a);
|
|
}
|
|
}
|
|
|
|
/* Drop black tabs whose command's whole process group has exited (e.g.
|
|
`kick /bin/false` -- sh runs false, the group empties, and the
|
|
tab would otherwise stay black forever). Runs from the main loop after a
|
|
SIGCHLD, never from the handler: it frees slots. Group liveness, not the
|
|
launcher pid's, decides: `sh -c "cmd &"` returns while cmd is still
|
|
starting, but cmd holds the group open, so kill(-groupid,0) succeeds and
|
|
the tab survives. */
|
|
/* Human words for a raw waitpid status, for the sweep's diagnostics. */
|
|
static void describe_child_status(int status, char *buf, size_t cap) {
|
|
if (WIFEXITED(status))
|
|
snprintf(buf, cap, "exit code %d", WEXITSTATUS(status));
|
|
else if (WIFSIGNALED(status))
|
|
snprintf(buf, cap, "killed by signal %d", WTERMSIG(status));
|
|
else
|
|
snprintf(buf, cap, "status 0x%x", status);
|
|
}
|
|
|
|
static void sweep_dead_startup_groups(void) {
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
struct kick_app *a = server.apps[i];
|
|
if (a->toplevel || !a->pending_claim || a->closing || a->pid <= 0)
|
|
continue;
|
|
errno = 0;
|
|
if (kill(-a->pid, 0) == 0 || errno == EPERM)
|
|
continue; /* the group still has a member */
|
|
if (errno != ESRCH)
|
|
continue;
|
|
/* Say why on stderr regardless of log level: at default levels the
|
|
client's own output is /dev/null'd and kick_log INFO is hidden, so
|
|
a command that dies before opening its window would otherwise end
|
|
the session (last tab) or shrink it with no visible explanation. */
|
|
char why[64];
|
|
why[0] = '\0';
|
|
if ((pid_t)last_child_pid == a->pid)
|
|
describe_child_status((int)last_child_status, why, sizeof(why));
|
|
const char *cmd = a->cmd ? a->cmd : "?";
|
|
if (why[0])
|
|
fprintf(stderr, "kick: tab %d (%s): command exited without "
|
|
"opening a window (%s)\n", i, cmd, why);
|
|
else
|
|
fprintf(stderr, "kick: tab %d (%s): command exited without "
|
|
"opening a window\n", i, cmd);
|
|
if (server.active_app == a) {
|
|
server.active_app = NULL;
|
|
struct kick_app *succ = pick_successor(a, -1);
|
|
if (!succ) succ = pick_successor(a, +1);
|
|
if (succ)
|
|
set_active_app(succ);
|
|
else {
|
|
/* the last tab died before ever connecting: tear down */
|
|
fprintf(stderr, "kick: no tabs left to show, exiting\n");
|
|
host_teardown_window();
|
|
shutdown_cleanup();
|
|
exit(0);
|
|
}
|
|
}
|
|
remove_app(i);
|
|
i--;
|
|
}
|
|
/* If the sweep's succession handed active to a slot that this same pass
|
|
then removed, put the crown back on some live tab (or nothing). */
|
|
if (!server.active_app) {
|
|
struct kick_app *succ = NULL;
|
|
for (int i = 0; i < server.num_apps; i++)
|
|
if (app_live(server.apps[i]) && !server.apps[i]->closing) {
|
|
succ = server.apps[i];
|
|
break;
|
|
}
|
|
if (succ)
|
|
set_active_app(succ);
|
|
}
|
|
}
|
|
|
|
/* Drain the SIGCHLD latch and reap dead startup groups; called from both
|
|
event loops after each dispatch. */
|
|
static void sweep_dead_startup(void) {
|
|
if (!sigchld_seen) return;
|
|
sigchld_seen = 0;
|
|
sweep_dead_startup_groups();
|
|
}
|
|
|
|
/* Add a new tab launching `cmd` and start it, exactly as if `cmd` had been
|
|
given as a command-line argument. The tab is inserted in the slot order
|
|
right after the currently active tab (falling back to the far right if
|
|
none is active), so the new tab sits to its right in the cycle. Returns
|
|
the new app index, or -1 on allocation failure. */
|
|
static int add_tab(const char *cmd) {
|
|
if (apps_ensure_cap(server.num_apps + 1) != 0) return -1;
|
|
struct kick_app *app = calloc(1, sizeof(*app));
|
|
if (!app) return -1;
|
|
app->cmd = strdup(cmd);
|
|
app->pid = -1;
|
|
app->pending_claim = true;
|
|
app->origin = ORIGIN_ADD_TAB;
|
|
wl_list_init(&app->group_members);
|
|
|
|
/* Anchor on the active slot itself, not its toplevel: while the active
|
|
tab is still black (its client has not connected yet) it has no
|
|
toplevel, so anchoring on the toplevel would append the new terminal
|
|
far right instead of beside the current tab. */
|
|
insert_app_after(app, server.active_app);
|
|
int idx = app_index(app);
|
|
launch_client(app);
|
|
return idx;
|
|
}
|
|
|
|
/* ===================== 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);
|
|
remove_socket_file();
|
|
_exit(139);
|
|
}
|
|
|
|
static void sigchld_handler(int sig) {
|
|
(void)sig;
|
|
sigchld_seen = 1;
|
|
int status;
|
|
pid_t pid;
|
|
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
|
|
last_child_pid = (sig_atomic_t)pid;
|
|
last_child_status = (sig_atomic_t)status;
|
|
if (kick_log_level() >= KICK_LOG_DEBUG) {
|
|
/* snprintf()/write() are async-signal-safe; fprintf() is not (it
|
|
takes the FILE lock, which can deadlock if we interrupt a logging
|
|
fprintf in the main loop). */
|
|
char buf[64];
|
|
int n = snprintf(buf, sizeof(buf),
|
|
"[child] pid %d exited status 0x%x\n", pid, status);
|
|
if (n > 0 && (size_t)n < sizeof(buf))
|
|
(void)!write(STDERR_FILENO, buf, (size_t)n);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 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.
|
|
|
|
These signals are delivered through the event loop (wl_event_loop_add_signal
|
|
in main) rather than a raw sigaction handler: the full teardown calls
|
|
malloc/free and libwayland proxy destroys, which are not async-signal-safe
|
|
and deadlock if the signal interrupts fork()/malloc()/logging (observed:
|
|
SIGTERM landing inside launch_client's fork() left libc's allocator lock
|
|
held, and shutdown_cleanup() -> bridge_cleanup() -> calloc() blocked on it
|
|
forever). Running it from a normal loop callback makes that impossible. */
|
|
static int close_signal_cb(int sig, void *data) {
|
|
(void)sig;
|
|
(void)data;
|
|
shutdown_cleanup();
|
|
_exit(0);
|
|
return 0;
|
|
}
|
|
|
|
/* 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;
|
|
/* A signal can land while the loop is blocked in poll on an idle
|
|
compositor; the write wakes it so the dump still renders. */
|
|
if (server.sig_pipe[1] >= 0) {
|
|
char c = 1;
|
|
(void)!write(server.sig_pipe[1], &c, 1);
|
|
}
|
|
}
|
|
|
|
/* Drain the SIGUSR1 self-pipe wake; the render gate (not this callback)
|
|
decides what to present. */
|
|
static int sigpipe_cb(int fd, uint32_t mask, void *data) {
|
|
(void)mask; (void)data;
|
|
char buf[64];
|
|
while (read(fd, buf, sizeof(buf)) > 0)
|
|
;
|
|
return 0;
|
|
}
|
|
|
|
/* Does the comma-separated WLR_BACKENDS list contain `name`? */
|
|
static bool backends_include(const char *env, const char *name) {
|
|
size_t len = strlen(name);
|
|
for (const char *p = env; p && *p; ) {
|
|
const char *comma = strchr(p, ',');
|
|
size_t n = comma ? (size_t)(comma - p) : strlen(p);
|
|
if (n == len && strncmp(p, name, len) == 0)
|
|
return true;
|
|
p = comma ? comma + 1 : NULL;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* ===================== Main ===================== */
|
|
|
|
/* Parse a --screen-mode value: WxH or WxH@Hz (the Hz is matched at the
|
|
mode's mHz precision, so "60" == 60.000). Stores into mode_req_w/h/
|
|
refresh; returns false on a malformed value. */
|
|
static bool parse_screen_mode(const char *val) {
|
|
int w = 0, h = 0;
|
|
const char *p = val;
|
|
for (; *p >= '0' && *p <= '9'; p++)
|
|
w = w * 10 + (*p - '0');
|
|
if (*p != 'x')
|
|
return false;
|
|
p++;
|
|
for (; *p >= '0' && *p <= '9'; p++)
|
|
h = h * 10 + (*p - '0');
|
|
if (w <= 0 || h <= 0)
|
|
return false;
|
|
unsigned refresh_mhz = 0;
|
|
if (*p == '@') {
|
|
char *end = NULL;
|
|
double hz = strtod(p + 1, &end);
|
|
if (end == p + 1 || hz <= 0.0 || *end != '\0')
|
|
return false;
|
|
refresh_mhz = (unsigned)(hz * 1000.0 + 0.5);
|
|
} else if (*p != '\0') {
|
|
return false;
|
|
}
|
|
mode_req_w = w;
|
|
mode_req_h = h;
|
|
mode_req_refresh = (int)refresh_mhz;
|
|
return true;
|
|
}
|
|
|
|
int main(int argc, char *argv[]) {
|
|
snprintf(kick_socket, sizeof(kick_socket), "kick-%d",
|
|
(int)getpid());
|
|
const char *runtime = getenv("XDG_RUNTIME_DIR");
|
|
if (!runtime) runtime = "/tmp";
|
|
snprintf(socket_path, sizeof(socket_path), "%s/%s", runtime, kick_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();
|
|
|
|
/* Resolve KICK_LOG up front so signal handlers can read the cached
|
|
level without touching non-async-signal-safe functions. */
|
|
kick_log_level_init();
|
|
|
|
/* KICK_SYNTH_INPUT: keep real host keyboard events out of the test
|
|
channel so the stdin feed is the only source of key/modifier input. */
|
|
{
|
|
const char *v = getenv("KICK_SYNTH_INPUT");
|
|
synth_input_only = v && v[0] && v[0] != '0';
|
|
}
|
|
|
|
/* Alt+Shift+T opens a terminal named by $TERMINAL when set, else the
|
|
alacritty default. Every command-line argument is one tab launcher,
|
|
except --screen-mode WxH[@Hz], which selects the native output mode and
|
|
is consumed here, --modifier alt|super, which picks the modifier
|
|
prefix for the tab-management chords, and --start-cmd-index N, which
|
|
picks the command (1-based) that starts focused. Handle all of this up
|
|
front so a missing command (or a bad request) can be reported and the
|
|
process can exit before any window is created. */
|
|
{
|
|
const char *env_term = getenv("TERMINAL");
|
|
if (env_term && env_term[0])
|
|
term_cmd = env_term;
|
|
}
|
|
char **appv = calloc((size_t)argc, sizeof(*appv));
|
|
if (!appv) {
|
|
fprintf(stderr, "kick: out of memory\n");
|
|
return 1;
|
|
}
|
|
int napps = 0;
|
|
for (int i = 1; i < argc; i++) {
|
|
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
|
fprintf(stderr,
|
|
"kick Tab Compositor\n"
|
|
"\n"
|
|
"kick opens one tab per command-line argument. Each "
|
|
"argument is a shell\n"
|
|
"command; quote it when it takes arguments. With no "
|
|
"arguments a single\n"
|
|
"terminal tab opens ($TERMINAL, default alacritty).\n"
|
|
"\n"
|
|
"Tabs are managed with chords on a modifier prefix -- "
|
|
"Alt by default;\n"
|
|
"--modifier super moves them to Super.\n"
|
|
"\n"
|
|
"Usage:\n"
|
|
" %s [options] [prog...]\n"
|
|
" %s -h | --help\n"
|
|
"\n"
|
|
"Example:\n"
|
|
" # kick \"firefox -no-remote\"\n"
|
|
"\n"
|
|
"Options:\n"
|
|
" --screen-mode WxH[@Hz] Select the native output "
|
|
"mode (standalone\n"
|
|
" only), e.g. 1920x1080 or "
|
|
"1920x1080@60. A\n"
|
|
" request nothing supports "
|
|
"prints every mode\n"
|
|
" and exits.\n"
|
|
" --modifier alt|super Pick the tab-management "
|
|
"modifier (default\n"
|
|
" alt). 'super' suits "
|
|
"standalone/DRM and\n"
|
|
" labwc-style hosts.\n"
|
|
" --start-cmd-index N Pick the command (1-based) "
|
|
"that starts\n"
|
|
" focused; without it the "
|
|
"last command does.\n"
|
|
" --tab-groups on|off How a client's related "
|
|
"windows group with their tab:\n"
|
|
" 'on' (default) stacks them "
|
|
"over the parent tab,\n"
|
|
" sharing its visibility and "
|
|
"focus (a browser-style\n"
|
|
" tab group); 'off' gives each "
|
|
"related window its own\n"
|
|
" tab right of the parent.\n"
|
|
" -h, --help Show the full help.\n"
|
|
"\n"
|
|
"Keys (hold the modifier):\n"
|
|
" Up / Down Cycle tabs; hold to keep "
|
|
"cycling.\n"
|
|
" Shift+Up / Shift+Down Reorder the active tab.\n"
|
|
" Delete Close the active window (or the tab when it is the only window).\n"
|
|
" Shift+T Open a new terminal tab "
|
|
"($TERMINAL).\n"
|
|
" Left / Right Cycle the active tab's "
|
|
"tab-group windows (Left = previous, Right = next); hold "
|
|
"to keep cycling.\n"
|
|
" Ctrl+Alt+F1..F12 Switch virtual terminals "
|
|
"(standalone only).\n",
|
|
argv[0], argv[0]);
|
|
free(appv);
|
|
return 0;
|
|
}
|
|
const char *val = NULL;
|
|
if (strcmp(argv[i], "--screen-mode") == 0) {
|
|
if (i + 1 >= argc) {
|
|
fprintf(stderr, "kick: --screen-mode needs a value, "
|
|
"e.g. --screen-mode 1920x1080\n");
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
val = argv[++i];
|
|
} else if (strncmp(argv[i], "--screen-mode=", 14) == 0) {
|
|
val = argv[i] + 14;
|
|
} else if (strcmp(argv[i], "--modifier") == 0) {
|
|
if (i + 1 >= argc) {
|
|
fprintf(stderr, "kick: --modifier needs a value, "
|
|
"e.g. --modifier super\n");
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
const char *m = argv[++i];
|
|
if (strcmp(m, "alt") == 0) {
|
|
tab_prefix = PREFIX_ALT;
|
|
} else if (strcmp(m, "super") == 0) {
|
|
tab_prefix = PREFIX_SUPER;
|
|
} else {
|
|
fprintf(stderr, "kick: bad --modifier '%s' "
|
|
"(use 'alt' or 'super')\n", m);
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
continue;
|
|
} else if (strncmp(argv[i], "--modifier=", 11) == 0) {
|
|
const char *m = argv[i] + 11;
|
|
if (strcmp(m, "alt") == 0) {
|
|
tab_prefix = PREFIX_ALT;
|
|
} else if (strcmp(m, "super") == 0) {
|
|
tab_prefix = PREFIX_SUPER;
|
|
} else {
|
|
fprintf(stderr, "kick: bad --modifier '%s' "
|
|
"(use 'alt' or 'super')\n", m);
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
continue;
|
|
} else if (strcmp(argv[i], "--tab-groups") == 0) {
|
|
if (i + 1 >= argc) {
|
|
fprintf(stderr, "kick: --tab-groups needs a value "
|
|
"(on or off)\n");
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
const char *m = argv[++i];
|
|
if (strcmp(m, "off") == 0) {
|
|
tab_groups = false;
|
|
} else if (strcmp(m, "on") == 0) {
|
|
tab_groups = true;
|
|
} else {
|
|
fprintf(stderr, "kick: bad --tab-groups '%s' "
|
|
"(use 'on' or 'off')\n", m);
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
continue;
|
|
} else if (strncmp(argv[i], "--tab-groups=", 13) == 0) {
|
|
const char *m = argv[i] + 13;
|
|
if (strcmp(m, "off") == 0) {
|
|
tab_groups = false;
|
|
} else if (strcmp(m, "on") == 0) {
|
|
tab_groups = true;
|
|
} else {
|
|
fprintf(stderr, "kick: bad --tab-groups '%s' "
|
|
"(use 'on' or 'off')\n", m);
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
continue;
|
|
} else if (strcmp(argv[i], "--start-cmd-index") == 0) {
|
|
if (i + 1 >= argc) {
|
|
fprintf(stderr, "kick: --start-cmd-index needs a "
|
|
"value, e.g. --start-cmd-index 2\n");
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
const char *n = argv[++i];
|
|
char *end = NULL;
|
|
long idx = strtol(n, &end, 10);
|
|
if (end == n || *end != '\0' || idx < 1 || idx > 2147483647L) {
|
|
fprintf(stderr, "kick: bad --start-cmd-index '%s' "
|
|
"(use a 1-based command number, e.g. --start-cmd-index "
|
|
"2)\n", n);
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
/* Convert to the program's 0-based index as soon as possible. */
|
|
start_cmd_index = (int)idx - 1;
|
|
continue;
|
|
} else if (strncmp(argv[i], "--start-cmd-index=",
|
|
sizeof("--start-cmd-index=") - 1) == 0) {
|
|
const char *n = argv[i] + sizeof("--start-cmd-index=") - 1;
|
|
char *end = NULL;
|
|
long idx = strtol(n, &end, 10);
|
|
if (end == n || *end != '\0' || idx < 1 || idx > 2147483647L) {
|
|
fprintf(stderr, "kick: bad --start-cmd-index '%s' "
|
|
"(use a 1-based command number)\n", n);
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
start_cmd_index = (int)idx - 1;
|
|
continue;
|
|
}
|
|
if (val) {
|
|
if (!parse_screen_mode(val)) {
|
|
fprintf(stderr, "kick: bad --screen-mode '%s' "
|
|
"(use WxH or WxH@Hz, e.g. 1920x1080 or "
|
|
"1920x1080@60)\n", val);
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
continue;
|
|
}
|
|
appv[napps++] = argv[i];
|
|
}
|
|
int total_apps = napps;
|
|
bool default_tab = false;
|
|
if (total_apps == 0) {
|
|
/* No arguments: open one terminal tab, as Alt+Shift+T would. */
|
|
default_tab = true;
|
|
total_apps = 1;
|
|
}
|
|
if (start_cmd_index >= 0 && start_cmd_index >= total_apps) {
|
|
fprintf(stderr, "kick: --start-cmd-index %d is out of range: "
|
|
"1..%d (%d command%s)\n", start_cmd_index + 1, total_apps,
|
|
total_apps, total_apps == 1 ? "" : "s");
|
|
free(appv);
|
|
return 2;
|
|
}
|
|
for (int i = 0; i < total_apps; i++) {
|
|
const char *cmd = default_tab ? term_cmd : appv[i];
|
|
if (!cmd_in_path(cmd)) {
|
|
fprintf(stderr, "kick: '%s' not found in PATH%s\n",
|
|
cmd, default_tab ? " (set $TERMINAL)" : "");
|
|
free(appv);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
struct sigaction crash_sa = {
|
|
.sa_sigaction = crash_handler,
|
|
.sa_flags = SA_SIGINFO | SA_NODEFER,
|
|
};
|
|
sigaction(SIGSEGV, &crash_sa, NULL);
|
|
sigaction(SIGABRT, &crash_sa, NULL);
|
|
server.sig_pipe[0] = server.sig_pipe[1] = -1;
|
|
if (pipe(server.sig_pipe) != 0) {
|
|
fprintf(stderr, "kick: failed to create SIGUSR1 wake pipe\n");
|
|
return 1;
|
|
}
|
|
int pipe_flags = fcntl(server.sig_pipe[0], F_GETFL, 0);
|
|
if (pipe_flags < 0
|
|
|| fcntl(server.sig_pipe[0], F_SETFL,
|
|
pipe_flags | O_NONBLOCK) != 0) {
|
|
fprintf(stderr, "kick: failed to make SIGUSR1 pipe non-blocking\n");
|
|
return 1;
|
|
}
|
|
signal(SIGCHLD, sigchld_handler);
|
|
signal(SIGUSR1, sigusr1_handler);
|
|
|
|
/* wlroots logs follow KICK_LOG: quiet keeps only errors, info drops
|
|
protocol noise, debug shows everything. */
|
|
switch (kick_log_level()) {
|
|
case KICK_LOG_DEBUG:
|
|
wlr_log_init(WLR_DEBUG, NULL);
|
|
break;
|
|
case KICK_LOG_INFO:
|
|
wlr_log_init(WLR_INFO, NULL);
|
|
break;
|
|
default:
|
|
wlr_log_init(WLR_ERROR, NULL);
|
|
break;
|
|
}
|
|
|
|
/* Mode selection: KICK_MODE=standalone (or any forced WLR_BACKENDS)
|
|
drives a native output directly (DRM, or headless for tests); the
|
|
default nested mode blits a headless output into a host window. */
|
|
server.standalone = false;
|
|
{
|
|
const char *mode = getenv("KICK_MODE");
|
|
const char *wlr_backends = getenv("WLR_BACKENDS");
|
|
if (mode && strcmp(mode, "standalone") == 0)
|
|
server.standalone = true;
|
|
if (wlr_backends && wlr_backends[0])
|
|
server.standalone = true;
|
|
}
|
|
|
|
server.wl_display = wl_display_create();
|
|
if (!server.wl_display) {
|
|
fprintf(stderr, "kick: failed to create Wayland display\n");
|
|
return 1;
|
|
}
|
|
server.loop = wl_display_get_event_loop(server.wl_display);
|
|
wl_list_init(&server.kbdevs);
|
|
wl_list_init(&server.ptrdevs);
|
|
|
|
/* SIGUSR1 dump wake: the read end registers an event source so the byte
|
|
written by the handler interrupts the blocking dispatch below. */
|
|
server.sigpipe_src = wl_event_loop_add_fd(server.loop, server.sig_pipe[0],
|
|
WL_EVENT_READABLE, sigpipe_cb, NULL);
|
|
|
|
/* Route SIGTERM/SIGINT through the event loop (signalfd) so shutdown runs
|
|
in normal context; see close_signal_cb for why a raw handler is unsafe. */
|
|
wl_event_loop_add_signal(server.loop, SIGTERM, close_signal_cb, NULL);
|
|
wl_event_loop_add_signal(server.loop, SIGINT, close_signal_cb, NULL);
|
|
|
|
/* 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);
|
|
|
|
if (server.standalone) {
|
|
/* autocreate honours WLR_BACKENDS and otherwise picks the current
|
|
session's wayland/x11 backend (we run inside some compositor
|
|
elsewhere), so force DRM+libinput unless told otherwise. */
|
|
const char *wlr_backends = getenv("WLR_BACKENDS");
|
|
if (!wlr_backends || !wlr_backends[0]) {
|
|
wlr_backends = "drm,libinput";
|
|
setenv("WLR_BACKENDS", wlr_backends, 1);
|
|
}
|
|
server.standalone_headless = backends_include(wlr_backends, "headless");
|
|
/* autocreate wraps every backend in a wlr_multi_backend, which breaks
|
|
the plain headless backend's manual wlr_headless_add_output() below
|
|
(it asserts wlr_backend_is_headless()), so create the headless
|
|
backend directly instead. */
|
|
if (server.standalone_headless)
|
|
server.backend = wlr_headless_backend_create(server.loop);
|
|
else
|
|
server.backend = wlr_backend_autocreate(server.loop, &server.session);
|
|
} else {
|
|
server.backend = wlr_headless_backend_create(server.loop);
|
|
}
|
|
if (!server.backend) {
|
|
fprintf(stderr, "kick: failed to create %s backend\n",
|
|
server.standalone ? "backend (autocreate)"
|
|
: "headless backend");
|
|
return 1;
|
|
}
|
|
|
|
server.renderer = wlr_renderer_autocreate(server.backend);
|
|
if (!server.renderer) {
|
|
fprintf(stderr, "kick: no GPU renderer available\n");
|
|
return 1;
|
|
}
|
|
wlr_renderer_init_wl_display(server.renderer, server.wl_display);
|
|
|
|
/* Standalone has no host EGL context: build the overlay glyph atlas with
|
|
the wlr renderer instead (nested mode does this inside init_egl()). */
|
|
if (server.standalone)
|
|
overlay_font_init();
|
|
|
|
server.allocator = wlr_allocator_autocreate(server.backend, server.renderer);
|
|
if (!server.allocator) {
|
|
fprintf(stderr, "kick: failed to create allocator\n");
|
|
return 1;
|
|
}
|
|
|
|
/* 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. */
|
|
server.compositor = wlr_compositor_create(server.wl_display, 4,
|
|
server.renderer);
|
|
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);
|
|
wlr_data_control_manager_v1_create(server.wl_display);
|
|
|
|
/* 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);
|
|
|
|
server.scene = wlr_scene_create();
|
|
server.xdg_shell = wlr_xdg_shell_create(server.wl_display, 6);
|
|
|
|
/* Passive protocol globals that cost one global each and let clients run
|
|
at their best: presentation-time for frame pacing, xdg-output for
|
|
logical output geometry, viewporter for viewport-scaled buffers (e.g.
|
|
video). wlr_scene applies viewports to xdg surfaces automatically. */
|
|
server.presentation = wlr_presentation_create(server.wl_display,
|
|
server.backend, 1);
|
|
server.viewporter = wlr_viewporter_create(server.wl_display);
|
|
|
|
/* Let nested clients pick their cursor shape (cursor-shape-v1); the
|
|
manager is passive, so we handle request_set_shape ourselves below:
|
|
apply it to the standalone cursor or forward it to the host pointer. */
|
|
server.shape_mgr = wlr_cursor_shape_manager_v1_create(server.wl_display, 1);
|
|
if (server.shape_mgr) {
|
|
server.shape_listener.notify = handle_shape_set;
|
|
wl_signal_add(&server.shape_mgr->events.request_set_shape,
|
|
&server.shape_listener);
|
|
}
|
|
|
|
server.seat = wlr_seat_create(server.wl_display, "kick");
|
|
wlr_seat_set_capabilities(server.seat,
|
|
WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_KEYBOARD);
|
|
|
|
#if HAVE_XWAYLAND
|
|
/* XWayland: X11 apps get tabs like Wayland clients. Lazy start keeps an
|
|
idle session free of an Xwayland process; display_name is reserved at
|
|
create time, so launch_client can export DISPLAY right away. The XWM
|
|
bridges ICCCM focus and X11 selections onto the seat, which rides the
|
|
existing clipboard bridge out to the host. */
|
|
if (!getenv("KICK_NO_XWAYLAND")) {
|
|
/* Run our own plain Xwayland: ignore any WLR_XWAYLAND wrapper the
|
|
environment may point at (e.g. one that forces `-auth` and the
|
|
legacy X11 cookie dance). wlroots then execs the compiled-in
|
|
Xwayland with no -auth, so local clients connect freely and we
|
|
never touch the session Xauthority file. */
|
|
unsetenv("WLR_XWAYLAND");
|
|
server.xwayland = wlr_xwayland_create(server.wl_display,
|
|
server.compositor, true);
|
|
if (server.xwayland) {
|
|
wl_list_init(&server.or_windows);
|
|
wlr_xwayland_set_seat(server.xwayland, server.seat);
|
|
server.xwayland_new_surface.notify = handle_xwayland_new_surface;
|
|
wl_signal_add(&server.xwayland->events.new_surface,
|
|
&server.xwayland_new_surface);
|
|
server.xwayland_ready.notify = handle_xwayland_ready;
|
|
wl_signal_add(&server.xwayland->events.ready,
|
|
&server.xwayland_ready);
|
|
server.xwayland_destroy.notify = handle_xwayland_destroy;
|
|
wl_signal_add(&server.xwayland->events.destroy,
|
|
&server.xwayland_destroy);
|
|
} else {
|
|
kick_log(KICK_LOG_INFO, "kick: XWayland unavailable "
|
|
"(no Xwayland binary?)\n");
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/* Pointer constraints + relative pointer: games and creative apps ask for
|
|
these to grab the cursor and to get raw relative motion. The constraint
|
|
is activated/deactivated on seat pointer focus change; relative deltas
|
|
are forwarded from the input paths. */
|
|
server.pointer_constraints = wlr_pointer_constraints_v1_create(server.wl_display);
|
|
server.relative_pointer_mgr = wlr_relative_pointer_manager_v1_create(server.wl_display);
|
|
server.seat_pointer_focus.notify = handle_seat_pointer_focus;
|
|
wl_signal_add(&server.seat->pointer_state.events.focus_change,
|
|
&server.seat_pointer_focus);
|
|
/* Primary selection (zwp_primary_selection_v1): advertise the manager so
|
|
clients like text editors can set the selection with their own serial
|
|
(no roundtrip needed); the request handler installs it on the seat,
|
|
which distributes it to the other focused tab clients. */
|
|
server.primary_selection_mgr =
|
|
wlr_primary_selection_v1_device_manager_create(server.wl_display);
|
|
server.request_set_primary_selection.notify = seat_request_set_primary_selection;
|
|
wl_signal_add(&server.seat->events.request_set_primary_selection,
|
|
&server.request_set_primary_selection);
|
|
/* The request_set_selection handler is what actually performs the
|
|
wlr_seat_set_selection for clients using the data-control manager; the
|
|
host-forwarding it triggers is a no-op without an outer host (backend
|
|
NONE), so standalone needs it too or wl-copy-style clipboard requests
|
|
are silently dropped. */
|
|
bridge_attach_seat();
|
|
|
|
/* The output layout exists in both modes so xdg-output can advertise
|
|
logical output geometry to clients; nested mode simply has one output
|
|
(the host window) in the layout. */
|
|
server.output_layout = wlr_output_layout_create(server.wl_display);
|
|
if (!server.output_layout) {
|
|
fprintf(stderr, "kick: failed to create output layout\n");
|
|
return 1;
|
|
}
|
|
server.xdg_output_mgr = wlr_xdg_output_manager_v1_create(server.wl_display,
|
|
server.output_layout);
|
|
|
|
/* Standalone has no host compositor to draw a pointer, so give the
|
|
output a real wlr_cursor. Theme/size follow the XDG cursor convention:
|
|
XCURSOR_THEME (default "default") and XCURSOR_SIZE (default 24px).
|
|
wlroots resolves the theme itself (search path, Inherits chains) and,
|
|
when nothing is installed, substitutes a builtin arrow -- so the pointer
|
|
is never invisible. We only report when the requested theme was
|
|
silently swapped for the builtin. */
|
|
if (server.standalone) {
|
|
server.cursor = wlr_cursor_create();
|
|
if (!server.cursor) {
|
|
fprintf(stderr, "kick: failed to create cursor\n");
|
|
return 1;
|
|
}
|
|
wlr_cursor_attach_output_layout(server.cursor, server.output_layout);
|
|
|
|
const char *theme_env = getenv("XCURSOR_THEME");
|
|
const char *theme = theme_env && *theme_env ? theme_env : "default";
|
|
|
|
uint32_t size = 24;
|
|
const char *size_env = getenv("XCURSOR_SIZE");
|
|
if (size_env) {
|
|
char *end = NULL;
|
|
unsigned long v = strtoul(size_env, &end, 10);
|
|
if (end != size_env && v > 0)
|
|
size = (uint32_t)v;
|
|
}
|
|
|
|
server.xcursor_mgr = wlr_xcursor_manager_create(theme, size);
|
|
if (server.xcursor_mgr)
|
|
wlr_xcursor_manager_load(server.xcursor_mgr, 1);
|
|
|
|
/* Detect the builtin from the manager's own state: wlr_xcursor_theme_load
|
|
renames the theme object to "default" when it substitutes the builtin
|
|
arrow (cursor_count == 0), so a mismatch with the requested name
|
|
means the theme didn't exist on disk. A theme literally named
|
|
"default" is indistinguishable (the builtin adopts that name), so
|
|
skip the warning there and only fail warm either way. */
|
|
struct wlr_xcursor *cur = server.xcursor_mgr ?
|
|
wlr_xcursor_manager_get_xcursor(server.xcursor_mgr, "default", 1)
|
|
: NULL;
|
|
if (cur && strcmp(theme, "default") != 0 &&
|
|
!wl_list_empty(&server.xcursor_mgr->scaled_themes)) {
|
|
struct wlr_xcursor_manager_theme *mt = wl_container_of(
|
|
server.xcursor_mgr->scaled_themes.next, mt, link);
|
|
if (mt->theme && mt->theme->name &&
|
|
strcmp(mt->theme->name, theme) != 0)
|
|
kick_log(KICK_LOG_INFO,
|
|
"[cursor] theme '%s' not found; wlroots builtin cursor "
|
|
"in use\n", theme);
|
|
}
|
|
if (!cur) {
|
|
kick_log(KICK_LOG_INFO,
|
|
"[cursor] no cursor theme available; no pointer shown\n");
|
|
} else {
|
|
wlr_cursor_set_xcursor(server.cursor, server.xcursor_mgr, "default");
|
|
wl_signal_add(&server.cursor->events.motion,
|
|
&standalone_cursor_motion_listener);
|
|
}
|
|
}
|
|
|
|
/* virtual keyboard fed from the host keyboard */
|
|
static const struct wlr_keyboard_impl keyboard_impl = {
|
|
.name = "kick-keyboard",
|
|
};
|
|
wlr_keyboard_init(&server.keyboard, &keyboard_impl, "kick-keyboard");
|
|
|
|
/* fallback keymap until the host sends one */
|
|
server.xkb_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
|
|
if (server.xkb_ctx) {
|
|
xkb_context_set_log_level(server.xkb_ctx, XKB_LOG_LEVEL_DEBUG);
|
|
xkb_context_set_log_fn(server.xkb_ctx, kick_xkb_log);
|
|
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);
|
|
xkb_state_reload();
|
|
}
|
|
}
|
|
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);
|
|
server.new_popup.notify = handle_new_popup;
|
|
wl_signal_add(&server.xdg_shell->events.new_popup, &server.new_popup);
|
|
|
|
/* Standalone: attach the native (libinput) input devices to the seat. */
|
|
if (server.standalone) {
|
|
server.new_input.notify = handle_new_input;
|
|
wl_signal_add(&server.backend->events.new_input, &server.new_input);
|
|
}
|
|
|
|
/* The headless backend (nested always, and standalone under
|
|
WLR_BACKENDS=headless when no real display is available) has no
|
|
outputs of its own, so add ours manually; DRM emits its outputs via
|
|
new_output. new_output fires during backend start. */
|
|
if (!server.standalone || server.standalone_headless) {
|
|
int w = server.standalone && mode_req_w > 0 ? mode_req_w : 1280;
|
|
int h = server.standalone && mode_req_h > 0 ? mode_req_h : 800;
|
|
host.width = w;
|
|
host.height = h;
|
|
wlr_headless_add_output(server.backend, host.width, host.height);
|
|
}
|
|
|
|
if (!wlr_backend_start(server.backend)) {
|
|
fprintf(stderr, "kick: failed to start backend\n");
|
|
return 1;
|
|
}
|
|
|
|
if (wl_display_add_socket(server.wl_display, kick_socket) < 0) {
|
|
fprintf(stderr, "kick: failed to add socket '%s': %s\n",
|
|
kick_socket, strerror(errno));
|
|
return 1;
|
|
}
|
|
|
|
/* Standalone mode presents onto the native output directly; nested mode
|
|
connects to the host compositor and presents inside it. */
|
|
if (!server.standalone && !host_init()) {
|
|
fprintf(stderr, "kick: could not start nested session\n");
|
|
remove_socket_file();
|
|
return 1;
|
|
}
|
|
|
|
kick_log(KICK_LOG_INFO, "Wayland socket: %s\n", kick_socket);
|
|
|
|
server.apps = calloc((size_t)total_apps, sizeof(*server.apps));
|
|
if (!server.apps) {
|
|
fprintf(stderr, "kick: out of memory for %d apps\n",
|
|
total_apps);
|
|
remove_socket_file();
|
|
return 1;
|
|
}
|
|
server.apps_cap = total_apps;
|
|
|
|
for (int i = 0; i < total_apps; i++) {
|
|
struct kick_app *app = calloc(1, sizeof(*app));
|
|
if (!app) {
|
|
remove_socket_file();
|
|
return 1;
|
|
}
|
|
app->cmd = strdup(default_tab ? term_cmd : appv[i]);
|
|
app->pid = -1;
|
|
app->origin = ORIGIN_CLI;
|
|
/* Live-but-black until the command claims the slot with a toplevel. */
|
|
app->pending_claim = true;
|
|
wl_list_init(&app->group_members);
|
|
server.apps[i] = app;
|
|
}
|
|
server.num_apps = total_apps;
|
|
|
|
for (int i = 0; i < server.num_apps; i++) {
|
|
if (server.apps[i]->cmd[0] == '-') {
|
|
if (i > 0) {
|
|
fprintf(stderr,
|
|
"kick: 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,
|
|
"kick: 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* The selected command-line command is active from the very first frame:
|
|
black, titled by its command line, until the client maps. The user
|
|
waits only on that one tab. --start-cmd-index picks it (1-based);
|
|
without the flag the last command is the one shown, so the startup tab
|
|
is always the one the caller can count on. */
|
|
int focus_idx = start_cmd_index >= 0 ? start_cmd_index : total_apps - 1;
|
|
set_active_app(server.apps[focus_idx]);
|
|
|
|
/* Fork every command-line command at once; the process-group match in
|
|
handle_new_toplevel gives each its own slot on arrival. */
|
|
launch_all_cli();
|
|
|
|
const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt";
|
|
kick_log(KICK_LOG_INFO, "kick: running. "
|
|
"%s+Up/Down switches tabs; %s+Shift+Up/Down reorders; "
|
|
"%s+Delete closes the active window (or the tab when it's the only window); "
|
|
"%s+Shift+T opens a terminal (%s).\n",
|
|
prefix, prefix, prefix, prefix, term_cmd);
|
|
|
|
/* Standalone event loop: no host display, so drive the server loop alone.
|
|
The native output's frame signal (or the headless 60Hz timer) wakes the
|
|
dispatcher and triggers a render. The loop fd is polled directly and
|
|
dispatched with timeout 0: dispatch(-1) would re-poll inside libwayland
|
|
after draining the SIGUSR1 self-pipe and never return, leaving the gate
|
|
below unevaluated on an idle screen. */
|
|
if (server.standalone) {
|
|
int server_loop_fd = wl_event_loop_get_fd(server.loop);
|
|
for (;;) {
|
|
struct pollfd fds[1] = {
|
|
{ .fd = server_loop_fd, .events = POLLIN },
|
|
};
|
|
int ret;
|
|
do {
|
|
ret = poll(fds, 1, -1);
|
|
} while (ret < 0 && errno == EINTR);
|
|
if (ret < 0)
|
|
continue;
|
|
|
|
do {
|
|
ret = wl_event_loop_dispatch(server.loop, 0);
|
|
} while (ret == -1 && errno == EINTR);
|
|
wl_display_flush_clients(server.wl_display);
|
|
sweep_dead_startup();
|
|
if (server.output && (wlr_output_is_headless(server.output)
|
|
|| !server.output->frame_pending)
|
|
&& (needs_redraw || server.output->needs_frame)) {
|
|
frame_pending = false;
|
|
needs_redraw = false;
|
|
render_and_present();
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Nested event loop: service the server, the host display, and libdecor
|
|
(whose decoration plugin runs its own event handling). */
|
|
int server_fd = wl_event_loop_get_fd(server.loop);
|
|
int host_fd = wl_display_get_fd(host.display);
|
|
int decor_fd = host.libdecor ? libdecor_get_fd(host.libdecor) : -1;
|
|
|
|
for (;;) {
|
|
wl_display_dispatch_pending(host.display);
|
|
libdecor_dispatch(host.libdecor, 0);
|
|
/* Flush queued nested-client messages before blocking: a data-control
|
|
`send` queued while dispatching the host side would otherwise sit
|
|
in the client connection until some later event wakes the poll
|
|
below, stalling host-pasted clipboard data for seconds. */
|
|
wl_display_flush_clients(server.wl_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[3] = {
|
|
{ .fd = server_fd, .events = POLLIN },
|
|
{ .fd = host_fd, .events = POLLIN },
|
|
{ .fd = decor_fd, .events = POLLIN },
|
|
};
|
|
int nfds = decor_fd >= 0 ? 3 : 2;
|
|
int ret;
|
|
do {
|
|
ret = poll(fds, nfds, -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);
|
|
sweep_dead_startup();
|
|
wl_display_dispatch_pending(host.display);
|
|
libdecor_dispatch(host.libdecor, 0);
|
|
|
|
/* Render only when something changed (kick's own redraws or scene
|
|
damage) and no page flip is in flight; an idle screen never
|
|
repaints. wl_scene schedules its own frame wake on client
|
|
damage, so a dirty scene always gets here. */
|
|
if (server.output && (wlr_output_is_headless(server.output)
|
|
|| !server.output->frame_pending)
|
|
&& (needs_redraw || server.output->needs_frame)) {
|
|
frame_pending = false;
|
|
needs_redraw = false;
|
|
render_and_present();
|
|
}
|
|
wl_display_flush(host.display);
|
|
}
|
|
|
|
/* Unreachable in practice: every shutdown path exits via
|
|
shutdown_cleanup(). Kept for completeness. */
|
|
shutdown_cleanup();
|
|
return 0;
|
|
}
|