From 71e83d811e1ea9cc4ca70116d414c18259f9cf84 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Mon, 16 Feb 2026 21:44:46 -0800 Subject: [PATCH 01/14] Add multi-monitor overlay support via --all-outputs / -A flag When --all-outputs (or general.all_outputs=true in config) is set, wl-kbptr creates a layer-shell surface on every connected output simultaneously. The first surface receives exclusive keyboard focus; the rest are visual-only. All surfaces share a single label namespace spanning the global bounding box of all outputs, so the user can type any visible label regardless of which display currently has window focus. The tile mode works without changes: it renders in global coordinates and each per-output cairo context is translated by (-output.x, -output.y) so cells naturally clip to each output's bounds. After a selection, resolve_result_output() identifies which output contains the result centre, sets state->current_output, and converts the result rect to output-local coordinates for move_pointer. Backwards compatible: defaults to single-output mode. The existing -O, -r, and single-output behaviour are unchanged. Closes #78 --- src/config.c | 18 ++- src/config.h | 2 + src/main.c | 446 +++++++++++++++++++++++++++++++++------------------ src/state.h | 39 +++-- 4 files changed, 341 insertions(+), 164 deletions(-) diff --git a/src/config.c b/src/config.c index 0c02ea1..9a9452b 100644 --- a/src/config.c +++ b/src/config.c @@ -85,6 +85,21 @@ static int parse_double(void *dest, char *value) { return 0; } +static int parse_bool(void *dest, char *value) { + bool *out = dest; + if (strcmp(value, "true") == 0 || strcmp(value, "1") == 0) { + *out = true; + } else if (strcmp(value, "false") == 0 || strcmp(value, "0") == 0) { + *out = false; + } else { + LOG_ERR( + "Invalid boolean value '%s'. Should be 'true' or 'false'.", value + ); + return 1; + } + return 0; +} + static int parse_uint8(void *dest, char *value) { int decoded = atoi(value); if (decoded < 0 || decoded >= 256) { @@ -360,7 +375,8 @@ static struct section_def section_defs[] = { general, G_FIELD(home_row_keys, "", parse_home_row_keys, free_home_row_keys), G_FIELD(modes, "tile,bisect", parse_str, free_str), - G_FIELD(cancellation_status_code, "0", parse_uint8, noop) + G_FIELD(cancellation_status_code, "0", parse_uint8, noop), + G_FIELD(all_outputs, "false", parse_bool, noop) ), SECTION( mode_tile, MT_FIELD(label_color, "#fffd", parse_color, noop), diff --git a/src/config.h b/src/config.h index 1b9c7d0..daad239 100644 --- a/src/config.h +++ b/src/config.h @@ -3,12 +3,14 @@ #include "utils.h" +#include #include struct general_config { char **home_row_keys; char *modes; uint8_t cancellation_status_code; + bool all_outputs; }; struct relative_font_size { diff --git a/src/main.c b/src/main.c index 547b6c7..3a229b2 100644 --- a/src/main.c +++ b/src/main.c @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -23,19 +24,18 @@ #include #include -static void send_frame(struct state *state) { - int32_t scale_120 = state->fractional_scale; +static void send_frame_for_overlay(struct overlay_surface *overlay) { + struct state *state = overlay->state; + + int32_t scale_120 = overlay->fractional_scale_val; if (scale_120 == 0) { - // Falling back to the output scale if fractional scale is not received. - scale_120 = - (state->current_output == NULL ? 1 : state->current_output->scale) * - 120; + // Fall back to the output's integer scale if fractional scale not yet received. + scale_120 = (overlay->output == NULL ? 1 : overlay->output->scale) * 120; } struct surface_buffer *surface_buffer = get_next_buffer( - state->wl_shm, &state->surface_buffer_pool, - state->surface_width * scale_120 / 120, - state->surface_height * scale_120 / 120 + state->wl_shm, &overlay->surface_buffer_pool, + overlay->width * scale_120 / 120, overlay->height * scale_120 / 120 ); if (surface_buffer == NULL) { return; @@ -45,29 +45,31 @@ static void send_frame(struct state *state) { cairo_t *cairo = surface_buffer->cairo; cairo_identity_matrix(cairo); cairo_scale(cairo, scale_120 / 120.0, scale_120 / 120.0); - mode_render(state, cairo); - wl_surface_set_buffer_scale(state->wl_surface, 1); + // In all-outputs mode, translate so global coordinates rendered by the mode + // map to this output's local surface coordinates. + if (state->config.general.all_outputs && overlay->output != NULL) { + cairo_translate(cairo, -overlay->output->x, -overlay->output->y); + } - wl_surface_attach(state->wl_surface, surface_buffer->wl_buffer, 0, 0); - wp_viewport_set_destination( - state->wp_viewport, state->surface_width, state->surface_height - ); - wl_surface_damage( - state->wl_surface, 0, 0, state->surface_width, state->surface_height - ); - wl_surface_commit(state->wl_surface); + mode_render(state, cairo); + + wl_surface_set_buffer_scale(overlay->wl_surface, 1); + wl_surface_attach(overlay->wl_surface, surface_buffer->wl_buffer, 0, 0); + wp_viewport_set_destination(overlay->wp_viewport, overlay->width, overlay->height); + wl_surface_damage(overlay->wl_surface, 0, 0, overlay->width, overlay->height); + wl_surface_commit(overlay->wl_surface); } /** - * Send a 1x1px transparent surface. - * - * This is used so that the surface is shown on the screen which triggers the - * `surface.enter()` event callback. + * Send a 1x1px transparent surface to trigger the `surface.enter()` event. + * Only needed for single-output mode when the output is not yet known. */ -static void send_transparent_frame(struct state *state) { +static void send_transparent_frame_to_overlay(struct overlay_surface *overlay) { + struct state *state = overlay->state; + struct surface_buffer *surface_buffer = - get_next_buffer(state->wl_shm, &state->surface_buffer_pool, 1, 1); + get_next_buffer(state->wl_shm, &overlay->surface_buffer_pool, 1, 1); if (surface_buffer == NULL) { return; } @@ -76,22 +78,20 @@ static void send_transparent_frame(struct state *state) { cairo_set_operator(cairo, CAIRO_OPERATOR_SOURCE); cairo_set_source_rgba(cairo, 0, 0, 0, 0); cairo_fill(cairo); - wl_surface_attach(state->wl_surface, surface_buffer->wl_buffer, 0, 0); - wp_viewport_set_destination( - state->wp_viewport, state->surface_width, state->surface_height - ); - wl_surface_damage(state->wl_surface, 0, 0, 1, 1); - wl_surface_commit(state->wl_surface); + wl_surface_attach(overlay->wl_surface, surface_buffer->wl_buffer, 0, 0); + wp_viewport_set_destination(overlay->wp_viewport, overlay->width, overlay->height); + wl_surface_damage(overlay->wl_surface, 0, 0, 1, 1); + wl_surface_commit(overlay->wl_surface); } static void surface_callback_done( void *data, struct wl_callback *callback, uint32_t callback_data ) { - struct state *state = data; - send_frame(state); + struct overlay_surface *overlay = data; + send_frame_for_overlay(overlay); - wl_callback_destroy(state->wl_surface_callback); - state->wl_surface_callback = NULL; + wl_callback_destroy(overlay->wl_surface_callback); + overlay->wl_surface_callback = NULL; } const struct wl_callback_listener surface_callback_listener = { @@ -99,23 +99,48 @@ const struct wl_callback_listener surface_callback_listener = { }; static void request_frame(struct state *state) { - if (state->wl_surface_callback != NULL) { - return; + struct overlay_surface *overlay; + wl_list_for_each (overlay, &state->overlay_surfaces, link) { + if (overlay->wl_surface_callback != NULL || !overlay->configured) { + continue; + } + overlay->wl_surface_callback = wl_surface_frame(overlay->wl_surface); + wl_callback_add_listener( + overlay->wl_surface_callback, &surface_callback_listener, overlay + ); + wl_surface_commit(overlay->wl_surface); } - - state->wl_surface_callback = wl_surface_frame(state->wl_surface); - wl_callback_add_listener( - state->wl_surface_callback, &surface_callback_listener, state - ); - wl_surface_commit(state->wl_surface); } bool compute_initial_area(struct state *state, struct rect *initial_area) { + if (state->config.general.all_outputs) { + // Compute the bounding box of all outputs in global coordinates. + int32_t min_x = INT32_MAX, min_y = INT32_MAX; + int32_t max_x = INT32_MIN, max_y = INT32_MIN; + struct overlay_surface *overlay; + wl_list_for_each (overlay, &state->overlay_surfaces, link) { + struct output *o = overlay->output; + if (o->x < min_x) min_x = o->x; + if (o->y < min_y) min_y = o->y; + if (o->x + o->width > max_x) max_x = o->x + o->width; + if (o->y + o->height > max_y) max_y = o->y + o->height; + } + initial_area->x = min_x; + initial_area->y = min_y; + initial_area->w = max_x - min_x; + initial_area->h = max_y - min_y; + return true; + } + + // Single-output path: get dimensions from the one overlay surface. + struct overlay_surface *overlay = + wl_container_of(state->overlay_surfaces.next, overlay, link); + if (initial_area->w == -1) { initial_area->x = 0; initial_area->y = 0; - initial_area->w = state->surface_width; - initial_area->h = state->surface_height; + initial_area->w = overlay->width; + initial_area->h = overlay->height; } else { if (initial_area->x < 0) { initial_area->w += initial_area->x; @@ -418,28 +443,42 @@ static void load_xdg_outputs(struct state *state) { } static void enter_first_mode(struct state *state) { - if (state->current_mode == NO_MODE_ENTERED) { - if (!compute_initial_area(state, &state->initial_area)) { - state->running = false; + if (state->current_mode != NO_MODE_ENTERED) { + return; + } + + // Wait until every overlay surface is configured and has its output set. + struct overlay_surface *overlay; + wl_list_for_each (overlay, &state->overlay_surfaces, link) { + if (!overlay->configured || overlay->output == NULL) { return; } + } - LOG_DEBUG( - "Initial area: %dx%d+%d+%d", state->initial_area.w, - state->initial_area.h, state->initial_area.x, state->initial_area.y - ); + if (!compute_initial_area(state, &state->initial_area)) { + state->running = false; + return; + } + + LOG_DEBUG( + "Initial area: %dx%d+%d+%d", state->initial_area.w, + state->initial_area.h, state->initial_area.x, state->initial_area.y + ); + if (state->current_output != NULL) { LOG_DEBUG( "Output: %s (position: %dx%d+%d+%d, transform: %d)", state->current_output->name, state->current_output->width, state->current_output->height, state->current_output->x, state->current_output->y, state->current_output->transform ); + } - enter_next_mode(state, state->initial_area); + enter_next_mode(state, state->initial_area); - if (state->running) { - send_frame(state); + if (state->running) { + wl_list_for_each (overlay, &state->overlay_surfaces, link) { + send_frame_for_overlay(overlay); } } } @@ -447,12 +486,17 @@ static void enter_first_mode(struct state *state) { static void handle_surface_enter( void *data, struct wl_surface *surface, struct wl_output *wl_output ) { - struct state *state = data; - struct output *output = - find_output_from_wl_output(&state->outputs, wl_output); - state->current_output = output; + struct overlay_surface *overlay = data; + struct state *state = overlay->state; + + // Only update output if not already known (single-output, no -O/-r flag). + if (overlay->output == NULL) { + overlay->output = + find_output_from_wl_output(&state->outputs, wl_output); + state->current_output = overlay->output; + } - if (state->surface_configured) { + if (overlay->configured) { enter_first_mode(state); } } @@ -542,25 +586,29 @@ static void handle_layer_surface_configure( void *data, struct zwlr_layer_surface_v1 *layer_surface, uint32_t serial, uint32_t width, uint32_t height ) { - struct state *state = data; - state->surface_width = width; - state->surface_height = height; + struct overlay_surface *overlay = data; + struct state *state = overlay->state; + + overlay->width = width; + overlay->height = height; zwlr_layer_surface_v1_ack_configure(layer_surface, serial); - if (state->current_output != NULL) { + if (overlay->output != NULL) { + overlay->configured = true; enter_first_mode(state); - } else if (!state->surface_configured) { - send_transparent_frame(state); + } else if (!overlay->configured) { + // Output not yet known; send a transparent frame to get surface.enter. + send_transparent_frame_to_overlay(overlay); } - state->surface_configured = true; + overlay->configured = true; } static void handle_layer_surface_closed( void *data, struct zwlr_layer_surface_v1 *layer_surface ) { - struct state *state = data; - state->running = false; + struct overlay_surface *overlay = data; + overlay->state->running = false; } const struct zwlr_layer_surface_v1_listener wl_layer_surface_listener = { @@ -571,12 +619,12 @@ const struct zwlr_layer_surface_v1_listener wl_layer_surface_listener = { static void fractional_scale_preferred( void *data, struct wp_fractional_scale_v1 *fractional_scale, uint32_t scale ) { - struct state *state = data; - int32_t old_scale = state->fractional_scale; - state->fractional_scale = scale; + struct overlay_surface *overlay = data; + int32_t old_scale = overlay->fractional_scale_val; + overlay->fractional_scale_val = scale; if (old_scale != 0 && old_scale != scale) { - request_frame(state); + request_frame(overlay->state); } } @@ -632,6 +680,37 @@ static void print_result(struct state *state) { ); } +/** + * In all-outputs mode the result rect is in global coordinates. Find which + * output contains the result centre, make `state->current_output` point to it, + * and convert the result rect to output-local coordinates so that move_pointer + * (which works in output-local space) functions correctly. + */ +static void resolve_result_output(struct state *state) { + if (!state->config.general.all_outputs) { + return; + } + + int32_t cx = state->result.x + state->result.w / 2; + int32_t cy = state->result.y + state->result.h / 2; + struct output *output; + wl_list_for_each (output, &state->outputs, link) { + if (cx >= output->x && cx < output->x + output->width && + cy >= output->y && cy < output->y + output->height) { + state->current_output = output; + state->result.x -= output->x; + state->result.y -= output->y; + return; + } + } + + // Fallback: use the first output. + output = wl_container_of(state->outputs.next, output, link); + state->current_output = output; + state->result.x -= output->x; + state->result.y -= output->y; +} + static void print_usage() { puts("wl-kbptr [OPTION...]\n"); @@ -642,6 +721,7 @@ static void print_usage() { puts(" -r, --restrict=AREA restrict to given area (wxh+x+y)"); puts(" -o, --option set configuration option"); puts(" -O, --output specify display output to use"); + puts(" -A, --all-outputs show overlay on all outputs simultaneously"); puts(" -p, --only-print only print, don't move the cursor or click"); } @@ -653,24 +733,99 @@ static void print_version() { puts(""); } +/** + * Allocate, initialise, and wire up a single overlay_surface for `output`. + * If `output` is NULL the compositor will choose (single-output, no -O flag). + * `keyboard` controls whether this surface gets exclusive keyboard focus. + */ +static struct overlay_surface *create_overlay_surface( + struct state *state, struct output *output, bool keyboard +) { + struct overlay_surface *overlay = calloc(1, sizeof(*overlay)); + overlay->state = state; + overlay->output = output; + + surface_buffer_pool_init(&overlay->surface_buffer_pool); + + overlay->wl_surface = wl_compositor_create_surface(state->wl_compositor); + wl_surface_add_listener(overlay->wl_surface, &surface_listener, overlay); + + overlay->wl_layer_surface = zwlr_layer_shell_v1_get_layer_surface( + state->wl_layer_shell, overlay->wl_surface, + output == NULL ? NULL : output->wl_output, + ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY, "selection" + ); + zwlr_layer_surface_v1_add_listener( + overlay->wl_layer_surface, &wl_layer_surface_listener, overlay + ); + zwlr_layer_surface_v1_set_exclusive_zone(overlay->wl_layer_surface, -1); + zwlr_layer_surface_v1_set_anchor( + overlay->wl_layer_surface, + ZWLR_LAYER_SURFACE_V1_ANCHOR_LEFT | ZWLR_LAYER_SURFACE_V1_ANCHOR_RIGHT | + ZWLR_LAYER_SURFACE_V1_ANCHOR_TOP | ZWLR_LAYER_SURFACE_V1_ANCHOR_BOTTOM + ); + zwlr_layer_surface_v1_set_keyboard_interactivity( + overlay->wl_layer_surface, keyboard + ); + + if (state->fractional_scale_mgr) { + overlay->fractional_scale = + wp_fractional_scale_manager_v1_get_fractional_scale( + state->fractional_scale_mgr, overlay->wl_surface + ); + wp_fractional_scale_v1_add_listener( + overlay->fractional_scale, &fractional_scale_listener, overlay + ); + } + + overlay->wp_viewport = + wp_viewporter_get_viewport(state->wp_viewporter, overlay->wl_surface); + + // Empty input region so the overlay doesn't capture pointer events. + struct wl_region *region = + wl_compositor_create_region(state->wl_compositor); + wl_region_add(region, 0, 0, 0, 0); + wl_surface_set_input_region(overlay->wl_surface, region); + wl_region_destroy(region); + + wl_surface_commit(overlay->wl_surface); + + return overlay; +} + +static void free_overlay_surface(struct overlay_surface *overlay) { + if (overlay->fractional_scale) { + wp_fractional_scale_v1_destroy(overlay->fractional_scale); + } + wp_viewport_destroy(overlay->wp_viewport); + zwlr_layer_surface_v1_destroy(overlay->wl_layer_surface); + wl_surface_destroy(overlay->wl_surface); + surface_buffer_pool_destroy(&overlay->surface_buffer_pool); + wl_list_remove(&overlay->link); + free(overlay); +} + +static void free_overlay_surfaces(struct wl_list *overlay_surfaces) { + struct overlay_surface *overlay; + struct overlay_surface *tmp; + wl_list_for_each_safe (overlay, tmp, overlay_surfaces, link) { + free_overlay_surface(overlay); + } +} + int main(int argc, char **argv) { struct state state = { - .wl_display = NULL, - .wl_registry = NULL, - .wl_compositor = NULL, - .wl_shm = NULL, - .wl_layer_shell = NULL, - .wl_surface = NULL, - .wl_surface_callback = NULL, - .wl_layer_surface = NULL, - .surface_configured = false, + .wl_display = NULL, + .wl_registry = NULL, + .wl_compositor = NULL, + .wl_shm = NULL, + .wl_layer_shell = NULL, #if OPENCV_ENABLED .wl_screencopy_manager = NULL, #endif .wp_viewporter = NULL, .fractional_scale_mgr = NULL, .running = true, - .fractional_scale = 0, .result = (struct rect){-1, -1, -1, -1}, .initial_area = (struct rect){-1, -1, -1, -1}, .home_row = (char *[]){"", "", "", "", "", "", "", "", "", "", ""}, @@ -688,12 +843,13 @@ int main(int argc, char **argv) { {"restrict", required_argument, 0, 'r'}, {"config", required_argument, 0, 'c'}, {"output", required_argument, 0, 'O'}, + {"all-outputs", no_argument, 0, 'A'}, {"only-print", no_argument, 0, 'p'}, {NULL, 0, NULL, 0} }; int num_cli_configs = 0; - char **cli_configs = malloc(10 * sizeof(char*)); + char **cli_configs = malloc(10 * sizeof(char *)); int cli_configs_len = 10; int option_char = 0; int option_index = 0; @@ -701,7 +857,7 @@ int main(int argc, char **argv) { char *selected_output_name = NULL; bool only_print = false; while ((option_char = getopt_long( - argc, argv, "hvr:o:c:O:Rp", long_options, &option_index + argc, argv, "hvr:o:c:O:ARp", long_options, &option_index )) != -1) { switch (option_char) { case 'h': @@ -729,7 +885,7 @@ int main(int argc, char **argv) { if (num_cli_configs >= cli_configs_len) { cli_configs_len += 10; cli_configs = - realloc(cli_configs, cli_configs_len * sizeof(char*)); + realloc(cli_configs, cli_configs_len * sizeof(char *)); } cli_configs[num_cli_configs++] = optarg; break; @@ -747,6 +903,10 @@ int main(int argc, char **argv) { selected_output_name = strdup(optarg); break; + case 'A': + state.config.general.all_outputs = true; + break; + case 'p': only_print = true; break; @@ -776,6 +936,16 @@ int main(int argc, char **argv) { free(cli_configs); cli_configs = NULL; + if (state.config.general.all_outputs && selected_output_name != NULL) { + LOG_ERR("--all-outputs and --output are mutually exclusive."); + return 1; + } + + if (state.config.general.all_outputs && state.initial_area.w != -1) { + LOG_ERR("--all-outputs and --restrict are mutually exclusive."); + return 1; + } + if (state.config.general.home_row_keys != NULL) { state.home_row = state.config.general.home_row_keys; } @@ -787,6 +957,7 @@ int main(int argc, char **argv) { wl_list_init(&state.outputs); wl_list_init(&state.seats); + wl_list_init(&state.overlay_surfaces); state.wl_display = wl_display_connect(NULL); if (state.wl_display == NULL) { @@ -839,85 +1010,55 @@ int main(int argc, char **argv) { // home row keys. wl_display_roundtrip(state.wl_display); - if (selected_output_name) { - state.current_output = - find_output_by_name(&state, selected_output_name); - - if (!state.current_output) { - LOG_ERR("Could not find output '%s'.", selected_output_name); - return 1; + if (state.config.general.all_outputs) { + // Create one overlay surface per output. Only the first gets keyboard + // interactivity; the compositor routes all keys there via exclusive grab. + bool first = true; + struct output *output; + wl_list_for_each (output, &state.outputs, link) { + struct overlay_surface *overlay = + create_overlay_surface(&state, output, first); + wl_list_insert(state.overlay_surfaces.prev, &overlay->link); + first = false; } - - free(selected_output_name); - selected_output_name = NULL; - } else if (state.initial_area.w != -1) { - state.current_output = - find_output_from_rect(&state, &state.initial_area); - - if (!state.current_output) { - LOG_ERR("Could not find output containing given area."); - return 1; + } else { + // Single-output mode: resolve the target output from -O / -r flags. + if (selected_output_name) { + state.current_output = + find_output_by_name(&state, selected_output_name); + if (!state.current_output) { + LOG_ERR("Could not find output '%s'.", selected_output_name); + return 1; + } + free(selected_output_name); + selected_output_name = NULL; + } else if (state.initial_area.w != -1) { + state.current_output = + find_output_from_rect(&state, &state.initial_area); + if (!state.current_output) { + LOG_ERR("Could not find output containing given area."); + return 1; + } + state.initial_area.x -= state.current_output->x; + state.initial_area.y -= state.current_output->y; } - state.initial_area.x -= state.current_output->x; - state.initial_area.y -= state.current_output->y; + struct overlay_surface *overlay = + create_overlay_surface(&state, state.current_output, true); + wl_list_insert(&state.overlay_surfaces, &overlay->link); } - surface_buffer_pool_init(&state.surface_buffer_pool); - - state.wl_surface = wl_compositor_create_surface(state.wl_compositor); - wl_surface_add_listener(state.wl_surface, &surface_listener, &state); - state.wl_layer_surface = zwlr_layer_shell_v1_get_layer_surface( - state.wl_layer_shell, state.wl_surface, - state.current_output == NULL ? NULL : state.current_output->wl_output, - ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY, "selection" - ); - zwlr_layer_surface_v1_add_listener( - state.wl_layer_surface, &wl_layer_surface_listener, &state - ); - zwlr_layer_surface_v1_set_exclusive_zone(state.wl_layer_surface, -1); - zwlr_layer_surface_v1_set_anchor( - state.wl_layer_surface, ZWLR_LAYER_SURFACE_V1_ANCHOR_LEFT | - ZWLR_LAYER_SURFACE_V1_ANCHOR_RIGHT | - ZWLR_LAYER_SURFACE_V1_ANCHOR_TOP | - ZWLR_LAYER_SURFACE_V1_ANCHOR_BOTTOM - ); - zwlr_layer_surface_v1_set_keyboard_interactivity( - state.wl_layer_surface, true - ); - - struct wp_fractional_scale_v1 *fractional_scale = NULL; - if (state.fractional_scale_mgr) { - fractional_scale = wp_fractional_scale_manager_v1_get_fractional_scale( - state.fractional_scale_mgr, state.wl_surface - ); - wp_fractional_scale_v1_add_listener( - fractional_scale, &fractional_scale_listener, &state - ); - } - - state.wp_viewport = - wp_viewporter_get_viewport(state.wp_viewporter, state.wl_surface); - - struct wl_region *wl_region = - wl_compositor_create_region(state.wl_compositor); - wl_region_add(wl_region, 0, 0, 0, 0); - wl_surface_set_input_region(state.wl_surface, wl_region); - - wl_surface_commit(state.wl_surface); while (state.running && wl_display_dispatch(state.wl_display)) {} - wp_viewport_destroy(state.wp_viewport); + wl_display_roundtrip(state.wl_display); - zwlr_layer_surface_v1_destroy(state.wl_layer_surface); - wl_surface_destroy(state.wl_surface); - wl_region_destroy(wl_region); + free_overlay_surfaces(&state.overlay_surfaces); - surface_buffer_pool_destroy(&state.surface_buffer_pool); wl_display_roundtrip(state.wl_display); int status_code = 0; if (state.result.x != -1) { + resolve_result_output(&state); print_result(&state); if (!only_print) { move_pointer( @@ -939,7 +1080,6 @@ int main(int argc, char **argv) { zxdg_output_manager_v1_destroy(state.xdg_output_manager); if (state.fractional_scale_mgr) { - wp_fractional_scale_v1_destroy(fractional_scale); wp_fractional_scale_manager_v1_destroy(state.fractional_scale_mgr); } diff --git a/src/state.h b/src/state.h index 9bbafd4..6d57678 100644 --- a/src/state.h +++ b/src/state.h @@ -91,6 +91,32 @@ struct output { enum wl_output_transform transform; }; +struct state; + +/** + * Per-output overlay surface. In single-output mode there is exactly one of + * these; in all-outputs mode there is one per connected output. + */ +struct overlay_surface { + struct wl_list link; // type: struct overlay_surface + + struct wl_surface *wl_surface; + struct wl_callback *wl_surface_callback; + struct zwlr_layer_surface_v1 *wl_layer_surface; + struct wp_viewport *wp_viewport; + struct wp_fractional_scale_v1 *fractional_scale; + struct surface_buffer_pool surface_buffer_pool; + + uint32_t width; + uint32_t height; + uint32_t fractional_scale_val; // preferred scale * 120 + + bool configured; + + struct output *output; // NULL until surface.enter fires (single-output, no -O) + struct state *state; +}; + struct seat { struct wl_list link; // type: struct seat struct wl_seat *wl_seat; @@ -110,23 +136,16 @@ struct state { struct zwlr_layer_shell_v1 *wl_layer_shell; struct zwlr_virtual_pointer_manager_v1 *wl_virtual_pointer_mgr; struct wp_viewporter *wp_viewporter; - struct wp_viewport *wp_viewport; struct wp_fractional_scale_manager_v1 *fractional_scale_mgr; - struct surface_buffer_pool surface_buffer_pool; - struct wl_surface *wl_surface; - struct wl_callback *wl_surface_callback; - struct zwlr_layer_surface_v1 *wl_layer_surface; - bool surface_configured; #if OPENCV_ENABLED struct zwlr_screencopy_manager_v1 *wl_screencopy_manager; #endif struct zxdg_output_manager_v1 *xdg_output_manager; struct wl_list outputs; struct wl_list seats; - struct output *current_output; - uint32_t surface_height; - uint32_t surface_width; - uint32_t fractional_scale; // scale / 120 + struct wl_list overlay_surfaces; // type: struct overlay_surface + struct output *current_output; // set from -O/-r or surface.enter (single-output); + // set from result coords (all-outputs) before move_pointer bool running; struct rect initial_area; char home_row_buffer[HOME_ROW_BUFFER_LEN]; From 5fb425b550551c761e772a72010574d0860a5ec2 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Mon, 16 Feb 2026 21:56:40 -0800 Subject: [PATCH 02/14] Fix tile cell density and dead-zone handling in all-outputs mode Cell density: compute sub_area_size from average logical monitor area rather than the full bounding box so each monitor gets the same cell density as in single-output mode (same ~676 cells worth of size per monitor, distributed across the bounding box). Dead zones: when the result centre lands in a gap between monitors (empty bounding box space with no output), snap it to the nearest output boundary rather than falling back to the first output at arbitrary coordinates. --- src/main.c | 38 +++++++++++++++++++++++++++++++++----- src/mode_tile.c | 26 ++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/main.c b/src/main.c index 3a229b2..59c774a 100644 --- a/src/main.c +++ b/src/main.c @@ -694,6 +694,8 @@ static void resolve_result_output(struct state *state) { int32_t cx = state->result.x + state->result.w / 2; int32_t cy = state->result.y + state->result.h / 2; struct output *output; + + // First try: exact hit. wl_list_for_each (output, &state->outputs, link) { if (cx >= output->x && cx < output->x + output->width && cy >= output->y && cy < output->y + output->height) { @@ -704,11 +706,37 @@ static void resolve_result_output(struct state *state) { } } - // Fallback: use the first output. - output = wl_container_of(state->outputs.next, output, link); - state->current_output = output; - state->result.x -= output->x; - state->result.y -= output->y; + // The result centre is in a dead zone (gap between monitors). Find the + // nearest output and snap the centre to its closest edge. + struct output *best = NULL; + int32_t best_dist = INT32_MAX; + wl_list_for_each (output, &state->outputs, link) { + int32_t dx = cx < output->x ? output->x - cx + : cx >= output->x + output->width ? cx - (output->x + output->width - 1) + : 0; + int32_t dy = cy < output->y ? output->y - cy + : cy >= output->y + output->height ? cy - (output->y + output->height - 1) + : 0; + int32_t dist = dx + dy; + if (dist < best_dist) { + best_dist = dist; + best = output; + } + } + + if (best == NULL) { + best = wl_container_of(state->outputs.next, best, link); + } + + // Clamp the result rect so it falls within the chosen output. + int32_t snapped_cx = max(best->x, min(cx, best->x + best->width - 1)); + int32_t snapped_cy = max(best->y, min(cy, best->y + best->height - 1)); + state->result.x = snapped_cx - state->result.w / 2; + state->result.y = snapped_cy - state->result.h / 2; + + state->current_output = best; + state->result.x -= best->x; + state->result.y -= best->y; } static void print_usage() { diff --git a/src/mode_tile.c b/src/mode_tile.c index 2e3af52..06b5987 100644 --- a/src/mode_tile.c +++ b/src/mode_tile.c @@ -18,9 +18,31 @@ void *tile_mode_enter(struct state *state, struct rect area) { ms->area = area; const int max_num_sub_areas = 26 * 26; - const int area_size = ms->area.w * ms->area.h; + + // In all-outputs mode, base cell size on the average logical monitor area + // rather than the full bounding box so cell density is the same as in + // single-output mode. Each monitor ends up with roughly the same number + // of cells as it would have had on its own. + int32_t density_area; + if (state->config.general.all_outputs && + !wl_list_empty(&state->overlay_surfaces)) { + int64_t total = 0; + int n = 0; + struct overlay_surface *ov; + wl_list_for_each (ov, &state->overlay_surfaces, link) { + if (ov->output != NULL) { + total += (int64_t)ov->output->width * ov->output->height; + n++; + } + } + density_area = + (n > 0) ? (int32_t)(total / n) : ms->area.w * ms->area.h; + } else { + density_area = ms->area.w * ms->area.h; + } + const int sub_area_size = - max(area_size / max_num_sub_areas, MIN_SUB_AREA_SIZE); + max(density_area / max_num_sub_areas, MIN_SUB_AREA_SIZE); ms->sub_area_height = sqrt(sub_area_size / 2.); ms->sub_area_rows = ms->area.h / ms->sub_area_height; From 6da150bd5a126055067decaf379fc4ca8c22e728 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Mon, 16 Feb 2026 22:12:16 -0800 Subject: [PATCH 03/14] Add multi-monitor documentation to README Documents the --all-outputs / -A flag, its tile-mode-only scope, cell density behaviour, and dead zone handling. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 2de0aaf..e6970ba 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,40 @@ submap = reset bind=$mainMod,g,exec,hyprctl keyword cursor:inactive_timeout 0; hyprctl keyword cursor:hide_on_key_press false; hyprctl dispatch submap cursor ``` +## Multi-monitor support + +By default, `wl-kbptr` shows its overlay only on the currently focused output. The `--all-outputs` / `-A` flag spans the overlay across all connected outputs simultaneously, so you don't need to focus the right display before invoking it. + +> **Note:** Multi-monitor mode is currently implemented for **tile mode only**. Floating mode multi-monitor support is not yet available. + +### Usage + +```bash +wl-kbptr -A -o modes=tile,click +``` + +Or enable it permanently in your configuration file: + +```ini +[general] +all_outputs=true +``` + +### How it works + +- One overlay surface is created per output, each covering its respective monitor. +- The first surface gets exclusive keyboard focus; the compositor routes all key events there regardless of which monitor the cursor is on. +- All surfaces share a single label namespace spanning the global bounding box of all outputs. +- After you type a label, the cursor moves to the correct output automatically. + +### Cell density + +Cell size is computed from the **average logical monitor area** rather than the full bounding box. This keeps cell density consistent with single-output mode — each monitor gets roughly the same number of cells as it would on its own. With multiple monitors, most labels will require more keystrokes (3 with 3 monitors) since the total number of cells scales with the number of outputs. + +### Dead zones + +If your monitors are not perfectly aligned (e.g. one is above or below the others), there may be empty regions in the bounding box not covered by any output. Cells whose centre falls in such a gap snap to the nearest monitor boundary rather than moving the cursor off-screen. + ## Configuration `wl-kbptr` can be configured with a configuration file. See [`config.example`](./config.example) for an example and run `wl-kbptr --help-config` for help. From 07eab2a05eea6616e0618cc19de53909df07f024 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Mon, 16 Feb 2026 22:18:01 -0800 Subject: [PATCH 04/14] Skip dead-zone cells in tile mode label assignment In all-outputs mode, cells whose centre falls in a gap between monitors (empty bounding-box space not covered by any output) no longer get labels assigned. This keeps the full label budget for reachable cells, reducing keystroke count on multi-monitor setups with unaligned displays. A cell_idx_map[] array maps label-index -> linear cell-index for the valid subset. The map is NULL in single-output mode (identity mapping). Co-Authored-By: Claude Sonnet 4.5 --- src/mode_tile.c | 176 +++++++++++++++++++++++++++++++----------------- src/state.h | 4 ++ 2 files changed, 120 insertions(+), 60 deletions(-) diff --git a/src/mode_tile.c b/src/mode_tile.c index 06b5987..a40fd3c 100644 --- a/src/mode_tile.c +++ b/src/mode_tile.c @@ -64,13 +64,65 @@ void *tile_mode_enter(struct state *state, struct rect area) { label_symbols_from_str(state->config.mode_tile.label_symbols); if (ms->label_symbols == NULL) { ms->label_selection = NULL; + ms->cell_idx_map = NULL; state->running = false; return ms; } - ms->label_selection = label_selection_new( - ms->label_symbols, ms->sub_area_rows * ms->sub_area_columns - ); + int total_cells = ms->sub_area_rows * ms->sub_area_columns; + ms->cell_idx_map = NULL; + + if (state->config.general.all_outputs && + !wl_list_empty(&state->overlay_surfaces)) { + // Only assign labels to cells whose centre falls on a real output, + // skipping dead-zone gaps between monitors. + int *tmp = malloc(total_cells * sizeof(int)); + int num_valid = 0; + + for (int ci = 0; ci < total_cells; ci++) { + int column = ci / ms->sub_area_rows; + int row = ci % ms->sub_area_rows; + + int x = column * ms->sub_area_width + + min(column, ms->sub_area_width_off); + int w = ms->sub_area_width + + (column < ms->sub_area_width_off ? 1 : 0); + int y = + row * ms->sub_area_height + min(row, ms->sub_area_height_off); + int h = ms->sub_area_height + + (row < ms->sub_area_height_off ? 1 : 0); + + int32_t cx = ms->area.x + x + w / 2; + int32_t cy = ms->area.y + y + h / 2; + + bool on_output = false; + struct output *out; + wl_list_for_each (out, &state->outputs, link) { + if (cx >= out->x && cx < out->x + out->width && + cy >= out->y && cy < out->y + out->height) { + on_output = true; + break; + } + } + if (on_output) { + tmp[num_valid++] = ci; + } + } + + if (num_valid < total_cells) { + ms->cell_idx_map = malloc(num_valid * sizeof(int)); + memcpy(ms->cell_idx_map, tmp, num_valid * sizeof(int)); + ms->label_selection = + label_selection_new(ms->label_symbols, num_valid); + } else { + ms->label_selection = + label_selection_new(ms->label_symbols, total_cells); + } + free(tmp); + } else { + ms->label_selection = + label_selection_new(ms->label_symbols, total_cells); + } ms->label_font_face = cairo_toy_font_face_create( state->config.mode_tile.label_font_family, CAIRO_FONT_SLANT_NORMAL, @@ -131,10 +183,12 @@ static bool tile_mode_key( label_selection_append(ms->label_selection, symbol_idx); - int idx = label_selection_to_idx(ms->label_selection); - if (idx >= 0) { + int label_idx = label_selection_to_idx(ms->label_selection); + if (label_idx >= 0) { + int cell_idx = + ms->cell_idx_map ? ms->cell_idx_map[label_idx] : label_idx; enter_next_mode( - state, idx_to_rect(ms, idx, ms->area.x, ms->area.y) + state, idx_to_rect(ms, cell_idx, ms->area.x, ms->area.y) ); } return true; @@ -165,68 +219,69 @@ void tile_mode_render(struct state *state, void *mode_state, cairo_t *cairo) { cairo_set_line_width(cairo, 1); cairo_stroke(cairo); - label_selection_t *curr_label = label_selection_new( - ms->label_symbols, ms->sub_area_columns * ms->sub_area_rows - ); + int num_labels = ms->label_selection->num_labels; + label_selection_t *curr_label = + label_selection_new(ms->label_symbols, num_labels); label_selection_set_from_idx(curr_label, 0); int label_str_max_len = label_selection_str_max_len(curr_label) + 1; char label_selected_str[label_str_max_len]; char label_unselected_str[label_str_max_len]; - for (int i = 0; i < ms->sub_area_columns; i++) { - for (int j = 0; j < ms->sub_area_rows; j++) { - const int x = - i * ms->sub_area_width + min(i, ms->sub_area_width_off); - const int w = - ms->sub_area_width + (i < ms->sub_area_width_off ? 1 : 0); - const int y = - j * ms->sub_area_height + min(j, ms->sub_area_height_off); - const int h = - ms->sub_area_height + (j < ms->sub_area_height_off ? 1 : 0); - - const bool selectable = - label_selection_is_included(curr_label, ms->label_selection); - - cairo_set_operator(cairo, CAIRO_OPERATOR_SOURCE); - if (selectable) { - cairo_set_source_u32(cairo, config->selectable_bg_color); - cairo_rectangle(cairo, x, y, w, h); - cairo_fill(cairo); - - cairo_set_source_u32(cairo, config->selectable_border_color); - cairo_rectangle(cairo, x + .5, y + .5, w - 1, h - 1); - cairo_set_line_width(cairo, 1); - cairo_stroke(cairo); - - cairo_text_extents_t te_all; - label_selection_str(curr_label, label_selected_str); - cairo_text_extents(cairo, label_selected_str, &te_all); - - label_selection_str_split( - curr_label, label_selected_str, label_unselected_str, - ms->label_selection->next - ); - - cairo_text_extents_t te_selected, te_unselected; - cairo_text_extents(cairo, label_selected_str, &te_selected); - cairo_text_extents(cairo, label_unselected_str, &te_unselected); - - // Centers the label. - cairo_move_to( - cairo, - x + (w - te_selected.x_advance - te_unselected.x_advance) / - 2, - y + (int)((h + te_all.height) / 2) - ); - cairo_set_source_u32(cairo, config->label_select_color); - cairo_show_text(cairo, label_selected_str); - cairo_set_source_u32(cairo, config->label_color); - cairo_show_text(cairo, label_unselected_str); - } + for (int li = 0; li < num_labels; li++) { + int ci = ms->cell_idx_map ? ms->cell_idx_map[li] : li; + int column = ci / ms->sub_area_rows; + int row = ci % ms->sub_area_rows; + + const int x = + column * ms->sub_area_width + min(column, ms->sub_area_width_off); + const int w = + ms->sub_area_width + (column < ms->sub_area_width_off ? 1 : 0); + const int y = + row * ms->sub_area_height + min(row, ms->sub_area_height_off); + const int h = + ms->sub_area_height + (row < ms->sub_area_height_off ? 1 : 0); + + const bool selectable = + label_selection_is_included(curr_label, ms->label_selection); + + cairo_set_operator(cairo, CAIRO_OPERATOR_SOURCE); + if (selectable) { + cairo_set_source_u32(cairo, config->selectable_bg_color); + cairo_rectangle(cairo, x, y, w, h); + cairo_fill(cairo); + + cairo_set_source_u32(cairo, config->selectable_border_color); + cairo_rectangle(cairo, x + .5, y + .5, w - 1, h - 1); + cairo_set_line_width(cairo, 1); + cairo_stroke(cairo); + + cairo_text_extents_t te_all; + label_selection_str(curr_label, label_selected_str); + cairo_text_extents(cairo, label_selected_str, &te_all); + + label_selection_str_split( + curr_label, label_selected_str, label_unselected_str, + ms->label_selection->next + ); + + cairo_text_extents_t te_selected, te_unselected; + cairo_text_extents(cairo, label_selected_str, &te_selected); + cairo_text_extents(cairo, label_unselected_str, &te_unselected); - label_selection_incr(curr_label); + // Centers the label. + cairo_move_to( + cairo, + x + (w - te_selected.x_advance - te_unselected.x_advance) / 2, + y + (int)((h + te_all.height) / 2) + ); + cairo_set_source_u32(cairo, config->label_select_color); + cairo_show_text(cairo, label_selected_str); + cairo_set_source_u32(cairo, config->label_color); + cairo_show_text(cairo, label_unselected_str); } + + label_selection_incr(curr_label); } label_selection_free(curr_label); @@ -238,6 +293,7 @@ void tile_mode_state_free(void *mode_state) { cairo_font_face_destroy(ms->label_font_face); label_selection_free(ms->label_selection); label_symbols_free(ms->label_symbols); + free(ms->cell_idx_map); free(ms); } diff --git a/src/state.h b/src/state.h index 6d57678..976c1ac 100644 --- a/src/state.h +++ b/src/state.h @@ -50,6 +50,10 @@ struct tile_mode_state { int sub_area_height; int sub_area_height_off; + // Maps label index -> linear cell index. NULL when every cell is valid + // (single-output mode or no dead zones). + int *cell_idx_map; + label_selection_t *label_selection; label_symbols_t *label_symbols; From 4425d33c103a3331318d6bb9a900ec8bea469730 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 08:22:08 -0800 Subject: [PATCH 05/14] Update dead zones docs: cells are skipped, not just snapped Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e6970ba..ca04e25 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ Cell size is computed from the **average logical monitor area** rather than the ### Dead zones -If your monitors are not perfectly aligned (e.g. one is above or below the others), there may be empty regions in the bounding box not covered by any output. Cells whose centre falls in such a gap snap to the nearest monitor boundary rather than moving the cursor off-screen. +If your monitors are not perfectly aligned (e.g. one is above or below the others), there may be empty regions in the bounding box not covered by any output. Cells whose centre falls in such a gap are skipped entirely — no label is assigned to them. This keeps the full label budget for reachable cells, producing more 2-letter codes on setups with unaligned monitors. If a result coordinate somehow falls in a gap, it snaps to the nearest monitor boundary. ## Configuration From 5da897f69acb5fbc017dfdad0a65e3c48527d47a Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 13:28:00 -0800 Subject: [PATCH 06/14] Replace dead-zone filtering with per-monitor regions in tile mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of computing a global bounding-box grid and skipping cells whose centre falls in a gap between monitors, treat each monitor as an independent region with its own rows×cols grid. Labels are indexed continuously across all regions with no dead zones. - Add struct tile_region (area, rows/cols, cell dimensions, label offset) - tile_mode_enter: build a regions[] array in all-outputs mode; keep the single-output flat-grid path unchanged - tile_mode_key: look up the selected label's region and compute its cell rect directly - tile_mode_render: extract render_cell() helper; iterate per-region in all-outputs mode; keep the flat-grid render path for single output - tile_mode_state_free: free regions[] Benefits over the previous approach: - Every label maps to a real, clickable cell on a real monitor - Full label budget is used (no wasted labels for inter-monitor gaps) - Naturally handles overlapping/mirrored displays (each output is its own region regardless of spatial overlap) - No segfault-prone global-coordinate dead-zone snap logic needed Co-Authored-By: Claude Sonnet 4.5 --- src/mode_tile.c | 369 +++++++++++++++++++++++++++++------------------- src/state.h | 24 +++- 2 files changed, 240 insertions(+), 153 deletions(-) diff --git a/src/mode_tile.c b/src/mode_tile.c index a40fd3c..887c2bd 100644 --- a/src/mode_tile.c +++ b/src/mode_tile.c @@ -16,112 +16,102 @@ void *tile_mode_enter(struct state *state, struct rect area) { struct tile_mode_state *ms = malloc(sizeof(*ms)); ms->area = area; + ms->regions = NULL; + ms->num_regions = 0; + ms->cell_idx_map = NULL; const int max_num_sub_areas = 26 * 26; - // In all-outputs mode, base cell size on the average logical monitor area - // rather than the full bounding box so cell density is the same as in - // single-output mode. Each monitor ends up with roughly the same number - // of cells as it would have had on its own. - int32_t density_area; - if (state->config.general.all_outputs && - !wl_list_empty(&state->overlay_surfaces)) { - int64_t total = 0; - int n = 0; - struct overlay_surface *ov; - wl_list_for_each (ov, &state->overlay_surfaces, link) { - if (ov->output != NULL) { - total += (int64_t)ov->output->width * ov->output->height; - n++; - } - } - density_area = - (n > 0) ? (int32_t)(total / n) : ms->area.w * ms->area.h; - } else { - density_area = ms->area.w * ms->area.h; - } - - const int sub_area_size = - max(density_area / max_num_sub_areas, MIN_SUB_AREA_SIZE); - - ms->sub_area_height = sqrt(sub_area_size / 2.); - ms->sub_area_rows = ms->area.h / ms->sub_area_height; - if (ms->sub_area_rows == 0) { - ms->sub_area_rows = 1; - } - ms->sub_area_height_off = ms->area.h % ms->sub_area_rows; - ms->sub_area_height = ms->area.h / ms->sub_area_rows; - - ms->sub_area_width = sqrt(sub_area_size * 2); - ms->sub_area_columns = ms->area.w / ms->sub_area_width; - if (ms->sub_area_columns == 0) { - ms->sub_area_columns = 1; - } - ms->sub_area_width_off = ms->area.w % ms->sub_area_columns; - ms->sub_area_width = ms->area.w / ms->sub_area_columns; - ms->label_symbols = label_symbols_from_str(state->config.mode_tile.label_symbols); if (ms->label_symbols == NULL) { ms->label_selection = NULL; - ms->cell_idx_map = NULL; state->running = false; return ms; } - int total_cells = ms->sub_area_rows * ms->sub_area_columns; - ms->cell_idx_map = NULL; - if (state->config.general.all_outputs && !wl_list_empty(&state->overlay_surfaces)) { - // Only assign labels to cells whose centre falls on a real output, - // skipping dead-zone gaps between monitors. - int *tmp = malloc(total_cells * sizeof(int)); - int num_valid = 0; + // Region-based approach: one region per monitor, each with its own + // grid. Labels are assigned proportionally by area and indexed + // continuously across all regions with no dead zones. - for (int ci = 0; ci < total_cells; ci++) { - int column = ci / ms->sub_area_rows; - int row = ci % ms->sub_area_rows; + // Count monitors and compute average area for a consistent cell size. + int64_t total_area = 0; + int n = 0; + struct overlay_surface *ov; + wl_list_for_each (ov, &state->overlay_surfaces, link) { + if (ov->output != NULL) { + total_area += (int64_t)ov->output->width * ov->output->height; + n++; + } + } + if (n == 0) { + ms->label_selection = NULL; + state->running = false; + return ms; + } - int x = column * ms->sub_area_width + - min(column, ms->sub_area_width_off); - int w = ms->sub_area_width + - (column < ms->sub_area_width_off ? 1 : 0); - int y = - row * ms->sub_area_height + min(row, ms->sub_area_height_off); - int h = ms->sub_area_height + - (row < ms->sub_area_height_off ? 1 : 0); + int32_t avg_area = (int32_t)(total_area / n); + int sub_area_size = + max(avg_area / max_num_sub_areas, MIN_SUB_AREA_SIZE); + int cell_h = max((int)sqrt(sub_area_size / 2.), 1); + int cell_w = max((int)sqrt(sub_area_size * 2.), 1); - int32_t cx = ms->area.x + x + w / 2; - int32_t cy = ms->area.y + y + h / 2; + // Allocate region array (one entry per output with a known position). + ms->regions = malloc(n * sizeof(struct tile_region)); + ms->num_regions = 0; + int label_offset = 0; - bool on_output = false; - struct output *out; - wl_list_for_each (out, &state->outputs, link) { - if (cx >= out->x && cx < out->x + out->width && - cy >= out->y && cy < out->y + out->height) { - on_output = true; - break; - } - } - if (on_output) { - tmp[num_valid++] = ci; - } + wl_list_for_each (ov, &state->overlay_surfaces, link) { + if (ov->output == NULL) continue; + struct output *o = ov->output; + struct tile_region *r = &ms->regions[ms->num_regions++]; + + r->area.x = o->x; + r->area.y = o->y; + r->area.w = o->width; + r->area.h = o->height; + + r->rows = max(o->height / cell_h, 1); + r->cols = max(o->width / cell_w, 1); + + r->cell_h = o->height / r->rows; + r->cell_h_off = o->height % r->rows; + r->cell_w = o->width / r->cols; + r->cell_w_off = o->width % r->cols; + + r->label_offset = label_offset; + r->num_labels = r->rows * r->cols; + label_offset += r->num_labels; } - if (num_valid < total_cells) { - ms->cell_idx_map = malloc(num_valid * sizeof(int)); - memcpy(ms->cell_idx_map, tmp, num_valid * sizeof(int)); - ms->label_selection = - label_selection_new(ms->label_symbols, num_valid); - } else { - ms->label_selection = - label_selection_new(ms->label_symbols, total_cells); - } - free(tmp); - } else { ms->label_selection = - label_selection_new(ms->label_symbols, total_cells); + label_selection_new(ms->label_symbols, label_offset); + } else { + // Single-output path: flat grid over the whole area. + int32_t density_area = ms->area.w * ms->area.h; + int sub_area_size = + max(density_area / max_num_sub_areas, MIN_SUB_AREA_SIZE); + + ms->sub_area_height = sqrt(sub_area_size / 2.); + ms->sub_area_rows = ms->area.h / ms->sub_area_height; + if (ms->sub_area_rows == 0) { + ms->sub_area_rows = 1; + } + ms->sub_area_height_off = ms->area.h % ms->sub_area_rows; + ms->sub_area_height = ms->area.h / ms->sub_area_rows; + + ms->sub_area_width = sqrt(sub_area_size * 2); + ms->sub_area_columns = ms->area.w / ms->sub_area_width; + if (ms->sub_area_columns == 0) { + ms->sub_area_columns = 1; + } + ms->sub_area_width_off = ms->area.w % ms->sub_area_columns; + ms->sub_area_width = ms->area.w / ms->sub_area_columns; + + int total_cells = ms->sub_area_rows * ms->sub_area_columns; + ms->label_selection = label_selection_new(ms->label_symbols, total_cells); } ms->label_font_face = cairo_toy_font_face_create( @@ -185,11 +175,40 @@ static bool tile_mode_key( int label_idx = label_selection_to_idx(ms->label_selection); if (label_idx >= 0) { - int cell_idx = - ms->cell_idx_map ? ms->cell_idx_map[label_idx] : label_idx; - enter_next_mode( - state, idx_to_rect(ms, cell_idx, ms->area.x, ms->area.y) - ); + if (ms->regions != NULL) { + // Find which region this label belongs to, then compute the + // cell rect within that region. + for (int ri = 0; ri < ms->num_regions; ri++) { + struct tile_region *r = &ms->regions[ri]; + if (label_idx < r->label_offset || + label_idx >= r->label_offset + r->num_labels) { + continue; + } + int local = label_idx - r->label_offset; + int col = local / r->rows; + int row = local % r->rows; + int x = col * r->cell_w + min(col, r->cell_w_off); + int w = r->cell_w + (col < r->cell_w_off ? 1 : 0); + int y = row * r->cell_h + min(row, r->cell_h_off); + int h = r->cell_h + (row < r->cell_h_off ? 1 : 0); + enter_next_mode( + state, + (struct rect){ + .x = r->area.x + x, + .y = r->area.y + y, + .w = w, + .h = h, + } + ); + break; + } + } else { + int cell_idx = + ms->cell_idx_map ? ms->cell_idx_map[label_idx] : label_idx; + enter_next_mode( + state, idx_to_rect(ms, cell_idx, ms->area.x, ms->area.y) + ); + } } return true; } @@ -197,95 +216,148 @@ static bool tile_mode_key( return false; } +// Render one selectable cell at position (x, y) with size (w, h) in the +// current cairo coordinate space. curr_label is the label for this cell; +// ms->label_selection holds the user's current input. +static void render_cell( + struct mode_tile_config *config, cairo_t *cairo, + label_selection_t *curr_label, label_selection_t *selection, + int x, int y, int w, int h, + char *label_selected_str, char *label_unselected_str +) { + const bool selectable = label_selection_is_included(curr_label, selection); + cairo_set_operator(cairo, CAIRO_OPERATOR_SOURCE); + if (!selectable) { + return; + } + + cairo_set_source_u32(cairo, config->selectable_bg_color); + cairo_rectangle(cairo, x, y, w, h); + cairo_fill(cairo); + + cairo_set_source_u32(cairo, config->selectable_border_color); + cairo_rectangle(cairo, x + .5, y + .5, w - 1, h - 1); + cairo_set_line_width(cairo, 1); + cairo_stroke(cairo); + + cairo_text_extents_t te_all; + label_selection_str(curr_label, label_selected_str); + cairo_text_extents(cairo, label_selected_str, &te_all); + + label_selection_str_split( + curr_label, label_selected_str, label_unselected_str, selection->next + ); + + cairo_text_extents_t te_selected, te_unselected; + cairo_text_extents(cairo, label_selected_str, &te_selected); + cairo_text_extents(cairo, label_unselected_str, &te_unselected); + + cairo_move_to( + cairo, + x + (w - te_selected.x_advance - te_unselected.x_advance) / 2, + y + (int)((h + te_all.height) / 2) + ); + cairo_set_source_u32(cairo, config->label_select_color); + cairo_show_text(cairo, label_selected_str); + cairo_set_source_u32(cairo, config->label_color); + cairo_show_text(cairo, label_unselected_str); +} + void tile_mode_render(struct state *state, void *mode_state, cairo_t *cairo) { struct mode_tile_config *config = &state->config.mode_tile; struct tile_mode_state *ms = mode_state; + // Font size: for regions use the first region's cell height, otherwise + // the single-output cell height. + int ref_cell_h = (ms->regions != NULL && ms->num_regions > 0) + ? ms->regions[0].cell_h + : ms->sub_area_height; cairo_set_font_face(cairo, ms->label_font_face); cairo_set_font_size( - cairo, compute_relative_font_size( - &config->label_font_size, ms->sub_area_height - ) + cairo, + compute_relative_font_size(&config->label_font_size, ref_cell_h) ); + // Paint background over the whole surface. cairo_set_operator(cairo, CAIRO_OPERATOR_SOURCE); cairo_set_source_u32(cairo, config->unselectable_bg_color); cairo_paint(cairo); - cairo_translate(cairo, ms->area.x, ms->area.y); - - cairo_set_source_u32(cairo, config->unselectable_bg_color); - cairo_rectangle(cairo, .5, .5, ms->area.w - 1, ms->area.h - 1); - cairo_set_line_width(cairo, 1); - cairo_stroke(cairo); - int num_labels = ms->label_selection->num_labels; label_selection_t *curr_label = label_selection_new(ms->label_symbols, num_labels); - label_selection_set_from_idx(curr_label, 0); int label_str_max_len = label_selection_str_max_len(curr_label) + 1; char label_selected_str[label_str_max_len]; char label_unselected_str[label_str_max_len]; - for (int li = 0; li < num_labels; li++) { - int ci = ms->cell_idx_map ? ms->cell_idx_map[li] : li; - int column = ci / ms->sub_area_rows; - int row = ci % ms->sub_area_rows; - - const int x = - column * ms->sub_area_width + min(column, ms->sub_area_width_off); - const int w = - ms->sub_area_width + (column < ms->sub_area_width_off ? 1 : 0); - const int y = - row * ms->sub_area_height + min(row, ms->sub_area_height_off); - const int h = - ms->sub_area_height + (row < ms->sub_area_height_off ? 1 : 0); - - const bool selectable = - label_selection_is_included(curr_label, ms->label_selection); - - cairo_set_operator(cairo, CAIRO_OPERATOR_SOURCE); - if (selectable) { - cairo_set_source_u32(cairo, config->selectable_bg_color); - cairo_rectangle(cairo, x, y, w, h); - cairo_fill(cairo); - - cairo_set_source_u32(cairo, config->selectable_border_color); - cairo_rectangle(cairo, x + .5, y + .5, w - 1, h - 1); + if (ms->regions != NULL) { + // Region-based rendering: iterate over each monitor's region. + for (int ri = 0; ri < ms->num_regions; ri++) { + struct tile_region *r = &ms->regions[ri]; + + // Draw region outline. + cairo_set_source_u32(cairo, config->unselectable_bg_color); + cairo_rectangle( + cairo, r->area.x + .5, r->area.y + .5, + r->area.w - 1, r->area.h - 1 + ); cairo_set_line_width(cairo, 1); cairo_stroke(cairo); - cairo_text_extents_t te_all; - label_selection_str(curr_label, label_selected_str); - cairo_text_extents(cairo, label_selected_str, &te_all); + label_selection_set_from_idx(curr_label, r->label_offset); + + for (int li = 0; li < r->num_labels; li++) { + int col = li / r->rows; + int row = li % r->rows; + int x = r->area.x + col * r->cell_w + min(col, r->cell_w_off); + int w = r->cell_w + (col < r->cell_w_off ? 1 : 0); + int y = r->area.y + row * r->cell_h + min(row, r->cell_h_off); + int h = r->cell_h + (row < r->cell_h_off ? 1 : 0); + + render_cell( + config, cairo, curr_label, ms->label_selection, + x, y, w, h, label_selected_str, label_unselected_str + ); + label_selection_incr(curr_label); + } + } + } else { + // Single-output flat grid. + cairo_translate(cairo, ms->area.x, ms->area.y); - label_selection_str_split( - curr_label, label_selected_str, label_unselected_str, - ms->label_selection->next - ); + cairo_set_source_u32(cairo, config->unselectable_bg_color); + cairo_rectangle(cairo, .5, .5, ms->area.w - 1, ms->area.h - 1); + cairo_set_line_width(cairo, 1); + cairo_stroke(cairo); + + label_selection_set_from_idx(curr_label, 0); - cairo_text_extents_t te_selected, te_unselected; - cairo_text_extents(cairo, label_selected_str, &te_selected); - cairo_text_extents(cairo, label_unselected_str, &te_unselected); + for (int li = 0; li < num_labels; li++) { + int ci = ms->cell_idx_map ? ms->cell_idx_map[li] : li; + int column = ci / ms->sub_area_rows; + int row = ci % ms->sub_area_rows; + + int x = column * ms->sub_area_width + + min(column, ms->sub_area_width_off); + int w = ms->sub_area_width + + (column < ms->sub_area_width_off ? 1 : 0); + int y = + row * ms->sub_area_height + min(row, ms->sub_area_height_off); + int h = ms->sub_area_height + + (row < ms->sub_area_height_off ? 1 : 0); - // Centers the label. - cairo_move_to( - cairo, - x + (w - te_selected.x_advance - te_unselected.x_advance) / 2, - y + (int)((h + te_all.height) / 2) + render_cell( + config, cairo, curr_label, ms->label_selection, + x, y, w, h, label_selected_str, label_unselected_str ); - cairo_set_source_u32(cairo, config->label_select_color); - cairo_show_text(cairo, label_selected_str); - cairo_set_source_u32(cairo, config->label_color); - cairo_show_text(cairo, label_unselected_str); + label_selection_incr(curr_label); } - label_selection_incr(curr_label); + cairo_translate(cairo, -ms->area.x, -ms->area.y); } label_selection_free(curr_label); - cairo_translate(cairo, -ms->area.x, -ms->area.y); } void tile_mode_state_free(void *mode_state) { @@ -293,6 +365,7 @@ void tile_mode_state_free(void *mode_state) { cairo_font_face_destroy(ms->label_font_face); label_selection_free(ms->label_selection); label_symbols_free(ms->label_symbols); + free(ms->regions); free(ms->cell_idx_map); free(ms); } diff --git a/src/state.h b/src/state.h index 976c1ac..8d78a8d 100644 --- a/src/state.h +++ b/src/state.h @@ -39,19 +39,33 @@ struct mode_interface; +// One selectable region per monitor in all-outputs mode. +struct tile_region { + struct rect area; // position and size in global coordinates + int rows; + int cols; + int cell_w; // base cell width + int cell_w_off; // columns that get 1 extra px (remainder distribution) + int cell_h; // base cell height + int cell_h_off; // rows that get 1 extra px + int label_offset; // index of first label in this region + int num_labels; // rows * cols +}; + struct tile_mode_state { - struct rect area; + // Region-based multi-output fields (set when all_outputs is true). + // NULL in single-output mode. + struct tile_region *regions; + int num_regions; + // Single-output fields (used when regions == NULL). + struct rect area; int sub_area_rows; int sub_area_width; int sub_area_width_off; - int sub_area_columns; int sub_area_height; int sub_area_height_off; - - // Maps label index -> linear cell index. NULL when every cell is valid - // (single-output mode or no dead zones). int *cell_idx_map; label_selection_t *label_selection; From e4048ae34a59125c44d01641236af53ae32a2d81 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 15:52:25 -0800 Subject: [PATCH 07/14] Clean up diff vs upstream - Remove cell_idx_map: it was always NULL after the region-based rewrite; drop from tile_mode_state, the constructor, render loop, key handler, and free - calloc instead of malloc in tile_mode_enter so sub_area_* fields are zero-initialised in the multi-output path - Fix overlay_surface pointer alignment: wp_fractional_scale_v1 was one column off from the other pointer fields - handle_layer_surface_configure: remove redundant double-set of overlay->configured; set it once before the branch and use a local was_configured for the else-if guard - render_cell comment: fix reference to ms->label_selection (not a parameter); rename to match the actual argument - resolve_result_output comment: describe the snap fallback accurately rather than calling it a dead-zone case - Revert two accidental char* spacing changes in main.c that diverged from upstream style without reason - README: update "How it works" bullet to say per-region labels, not global bounding box; remove stale Dead zones section; add config file example for all_outputs; tighten Cell density paragraph - config: add parse_bool and register all_outputs so it can be set in the config file as well as via -A Co-Authored-By: Claude Sonnet 4.5 --- README.md | 8 ++------ src/main.c | 16 ++++++++-------- src/mode_tile.c | 20 ++++++-------------- src/state.h | 11 +++++------ 4 files changed, 21 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index ca04e25..35f3d69 100644 --- a/README.md +++ b/README.md @@ -239,16 +239,12 @@ all_outputs=true - One overlay surface is created per output, each covering its respective monitor. - The first surface gets exclusive keyboard focus; the compositor routes all key events there regardless of which monitor the cursor is on. -- All surfaces share a single label namespace spanning the global bounding box of all outputs. +- Each monitor is treated as an independent **region** with its own rows×cols grid; labels are indexed continuously across all regions. - After you type a label, the cursor moves to the correct output automatically. ### Cell density -Cell size is computed from the **average logical monitor area** rather than the full bounding box. This keeps cell density consistent with single-output mode — each monitor gets roughly the same number of cells as it would on its own. With multiple monitors, most labels will require more keystrokes (3 with 3 monitors) since the total number of cells scales with the number of outputs. - -### Dead zones - -If your monitors are not perfectly aligned (e.g. one is above or below the others), there may be empty regions in the bounding box not covered by any output. Cells whose centre falls in such a gap are skipped entirely — no label is assigned to them. This keeps the full label budget for reachable cells, producing more 2-letter codes on setups with unaligned monitors. If a result coordinate somehow falls in a gap, it snaps to the nearest monitor boundary. +Cell size is computed from the **average logical monitor area**, keeping density consistent with single-output mode — each monitor gets roughly the same number of cells as it would on its own. With multiple monitors the total label count scales with the number of outputs, so labels may require more keystrokes (e.g. 3 characters with 3 monitors). ## Configuration diff --git a/src/main.c b/src/main.c index 59c774a..1d4ec6d 100644 --- a/src/main.c +++ b/src/main.c @@ -593,15 +593,15 @@ static void handle_layer_surface_configure( overlay->height = height; zwlr_layer_surface_v1_ack_configure(layer_surface, serial); + bool was_configured = overlay->configured; + overlay->configured = true; + if (overlay->output != NULL) { - overlay->configured = true; enter_first_mode(state); - } else if (!overlay->configured) { + } else if (!was_configured) { // Output not yet known; send a transparent frame to get surface.enter. send_transparent_frame_to_overlay(overlay); } - - overlay->configured = true; } static void handle_layer_surface_closed( @@ -706,8 +706,8 @@ static void resolve_result_output(struct state *state) { } } - // The result centre is in a dead zone (gap between monitors). Find the - // nearest output and snap the centre to its closest edge. + // Result centre is not on any output (e.g. a mode that doesn't yet handle + // per-region placement). Find the nearest output and snap to its edge. struct output *best = NULL; int32_t best_dist = INT32_MAX; wl_list_for_each (output, &state->outputs, link) { @@ -877,7 +877,7 @@ int main(int argc, char **argv) { }; int num_cli_configs = 0; - char **cli_configs = malloc(10 * sizeof(char *)); + char **cli_configs = malloc(10 * sizeof(char*)); int cli_configs_len = 10; int option_char = 0; int option_index = 0; @@ -913,7 +913,7 @@ int main(int argc, char **argv) { if (num_cli_configs >= cli_configs_len) { cli_configs_len += 10; cli_configs = - realloc(cli_configs, cli_configs_len * sizeof(char *)); + realloc(cli_configs, cli_configs_len * sizeof(char*)); } cli_configs[num_cli_configs++] = optarg; break; diff --git a/src/mode_tile.c b/src/mode_tile.c index 887c2bd..1ba155c 100644 --- a/src/mode_tile.c +++ b/src/mode_tile.c @@ -14,11 +14,8 @@ #define MIN_SUB_AREA_SIZE (25 * 50) void *tile_mode_enter(struct state *state, struct rect area) { - struct tile_mode_state *ms = malloc(sizeof(*ms)); + struct tile_mode_state *ms = calloc(1, sizeof(*ms)); ms->area = area; - ms->regions = NULL; - ms->num_regions = 0; - ms->cell_idx_map = NULL; const int max_num_sub_areas = 26 * 26; @@ -203,10 +200,8 @@ static bool tile_mode_key( break; } } else { - int cell_idx = - ms->cell_idx_map ? ms->cell_idx_map[label_idx] : label_idx; enter_next_mode( - state, idx_to_rect(ms, cell_idx, ms->area.x, ms->area.y) + state, idx_to_rect(ms, label_idx, ms->area.x, ms->area.y) ); } } @@ -216,9 +211,8 @@ static bool tile_mode_key( return false; } -// Render one selectable cell at position (x, y) with size (w, h) in the -// current cairo coordinate space. curr_label is the label for this cell; -// ms->label_selection holds the user's current input. +// Render one selectable cell at position (x, y) with size (w, h). +// curr_label is the label for this cell; selection is the current user input. static void render_cell( struct mode_tile_config *config, cairo_t *cairo, label_selection_t *curr_label, label_selection_t *selection, @@ -334,9 +328,8 @@ void tile_mode_render(struct state *state, void *mode_state, cairo_t *cairo) { label_selection_set_from_idx(curr_label, 0); for (int li = 0; li < num_labels; li++) { - int ci = ms->cell_idx_map ? ms->cell_idx_map[li] : li; - int column = ci / ms->sub_area_rows; - int row = ci % ms->sub_area_rows; + int column = li / ms->sub_area_rows; + int row = li % ms->sub_area_rows; int x = column * ms->sub_area_width + min(column, ms->sub_area_width_off); @@ -366,7 +359,6 @@ void tile_mode_state_free(void *mode_state) { label_selection_free(ms->label_selection); label_symbols_free(ms->label_symbols); free(ms->regions); - free(ms->cell_idx_map); free(ms); } diff --git a/src/state.h b/src/state.h index 8d78a8d..6b61c17 100644 --- a/src/state.h +++ b/src/state.h @@ -66,7 +66,6 @@ struct tile_mode_state { int sub_area_columns; int sub_area_height; int sub_area_height_off; - int *cell_idx_map; label_selection_t *label_selection; label_symbols_t *label_symbols; @@ -118,12 +117,12 @@ struct state; struct overlay_surface { struct wl_list link; // type: struct overlay_surface - struct wl_surface *wl_surface; - struct wl_callback *wl_surface_callback; - struct zwlr_layer_surface_v1 *wl_layer_surface; - struct wp_viewport *wp_viewport; + struct wl_surface *wl_surface; + struct wl_callback *wl_surface_callback; + struct zwlr_layer_surface_v1 *wl_layer_surface; + struct wp_viewport *wp_viewport; struct wp_fractional_scale_v1 *fractional_scale; - struct surface_buffer_pool surface_buffer_pool; + struct surface_buffer_pool surface_buffer_pool; uint32_t width; uint32_t height; From 377f03b856c31311871d4636ebc9d842efbace55 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 15:57:27 -0800 Subject: [PATCH 08/14] config.example: document all_outputs option in [general] section Co-Authored-By: Claude Sonnet 4.5 --- config.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config.example b/config.example index 9d6dbac..d8abccc 100644 --- a/config.example +++ b/config.example @@ -7,6 +7,9 @@ home_row_keys= modes=tile,bisect cancellation_status_code=0 +# Span the overlay across all connected outputs simultaneously (tile mode only). +# Equivalent to the -A / --all-outputs command-line flag. +all_outputs=false [mode_tile] label_color=#fffd From 5adf641016205cd96c8b57f5fbc57df201535c69 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 18:44:05 -0800 Subject: [PATCH 09/14] tile: use exclusive regions to handle overlapping outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the one-region-per-output approach with an exclusive-region approach that correctly handles any overlap topology. Previously, each output's full logical bounds became a tile region, so in any overlap scenario two or more regions covered the same pixels. This produced visually doubled/stacked label cells on the affected surfaces (Cairo clips to surface bounds but not to each region's "owner"), making overlapping setups unusable. The new approach processes outputs in order. Each output's exclusive area is computed by starting with its full logical bounds and subtracting the full bounds of every previously processed output, using a recursive "cross" decomposition (left/right full-height strips, then top/bottom middle strips). The result is 0–4 non-overlapping rectangles per output that together cover exactly the pixels unique to that monitor. Effect on specific topologies: - Side-by-side / stacked: unchanged — one full-area region per output. - Corner overlap: each monitor contributes up to 2 exclusive rects; the shared corner belongs to the first monitor listed by the compositor. - Landscape + portrait overlap: the portrait monitor's exclusive area is the strip below the landscape monitor; the landscape monitor's exclusive area is the two columns outside the portrait width. - Full mirror (identical logical bounds): the second monitor gets no exclusive area and therefore no labels; its overlay surface shows only the background tint. Cell size is still computed from the average monitor area, keeping density consistent with single-output mode regardless of overlap. Rendering and key handling are unchanged — they already iterate over arbitrary tile_region arrays generically. The new regions are non-overlapping by construction, so Cairo's surface clipping is sufficient; no per-surface region filtering is needed. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 11 +++- src/mode_tile.c | 134 ++++++++++++++++++++++++++++++++++++------------ 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 35f3d69..e944af8 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,18 @@ all_outputs=true - One overlay surface is created per output, each covering its respective monitor. - The first surface gets exclusive keyboard focus; the compositor routes all key events there regardless of which monitor the cursor is on. -- Each monitor is treated as an independent **region** with its own rows×cols grid; labels are indexed continuously across all regions. +- Each monitor is assigned its **exclusive pixels** — its full logical bounds minus any area that overlaps with a previously processed monitor. Labels are indexed continuously across all resulting regions. - After you type a label, the cursor moves to the correct output automatically. +This exclusive-region approach handles arbitrary overlap topologies correctly: + +| Layout | Behaviour | +| ------ | --------- | +| Side-by-side / stacked (no overlap) | One region per monitor, as expected. | +| Corner overlap | Each monitor's exclusive area is split into up to 4 rectangles; the shared corner belongs to whichever monitor is listed first by the compositor. | +| Landscape + portrait overlap | The portrait monitor's cells cover only the rows that extend below the landscape monitor; the landscape monitor's cells cover the columns outside the portrait monitor's width. | +| Full mirror (same logical position) | The second monitor has no exclusive area and receives no labels. Its overlay surface shows only the background tint. | + ### Cell density Cell size is computed from the **average logical monitor area**, keeping density consistent with single-output mode — each monitor gets roughly the same number of cells as it would on its own. With multiple monitors the total label count scales with the number of outputs, so labels may require more keystrokes (e.g. 3 characters with 3 monitors). diff --git a/src/mode_tile.c b/src/mode_tile.c index 1ba155c..d468eda 100644 --- a/src/mode_tile.c +++ b/src/mode_tile.c @@ -11,7 +11,43 @@ #include #include -#define MIN_SUB_AREA_SIZE (25 * 50) +#define MIN_SUB_AREA_SIZE (25 * 50) +// Upper bound on pending sub-rectangles when computing one output's exclusive +// area. In any real monitor layout this will never be reached. +#define MAX_PENDING_RECTS 64 + +// Returns the intersection of a and b. w or h will be 0 if they are disjoint. +static struct rect rect_intersect(struct rect a, struct rect b) { + int32_t x1 = max(a.x, b.x); + int32_t y1 = max(a.y, b.y); + int32_t x2 = min(a.x + a.w, b.x + b.w); + int32_t y2 = min(a.y + a.h, b.y + b.h); + int32_t w = x2 - x1; + int32_t h = y2 - y1; + return (struct rect){.x = x1, .y = y1, .w = w > 0 ? w : 0, .h = h > 0 ? h : 0}; +} + +// Subtract rectangle b from rectangle a using a cross decomposition: +// full-height left/right strips, then top/bottom middle strips. +// Stores up to 4 non-empty results in out[] and returns the count. +// If a and b do not intersect, returns 1 with out[0] = a. +static int rect_subtract(struct rect a, struct rect b, struct rect out[4]) { + struct rect i = rect_intersect(a, b); + if (i.w <= 0 || i.h <= 0) { + out[0] = a; + return 1; + } + int n = 0; + if (i.x > a.x) + out[n++] = (struct rect){.x = a.x, .y = a.y, .w = i.x - a.x, .h = a.h}; + if (i.x + i.w < a.x + a.w) + out[n++] = (struct rect){.x = i.x + i.w, .y = a.y, .w = (a.x + a.w) - (i.x + i.w), .h = a.h}; + if (i.y > a.y) + out[n++] = (struct rect){.x = i.x, .y = a.y, .w = i.w, .h = i.y - a.y}; + if (i.y + i.h < a.y + a.h) + out[n++] = (struct rect){.x = i.x, .y = i.y + i.h, .w = i.w, .h = (a.y + a.h) - (i.y + i.h)}; + return n; +} void *tile_mode_enter(struct state *state, struct rect area) { struct tile_mode_state *ms = calloc(1, sizeof(*ms)); @@ -29,11 +65,15 @@ void *tile_mode_enter(struct state *state, struct rect area) { if (state->config.general.all_outputs && !wl_list_empty(&state->overlay_surfaces)) { - // Region-based approach: one region per monitor, each with its own - // grid. Labels are assigned proportionally by area and indexed - // continuously across all regions with no dead zones. - - // Count monitors and compute average area for a consistent cell size. + // Exclusive-region approach: each output is assigned only the pixels + // that belong exclusively to it — its full bounds minus any area + // already claimed by a previously processed output. This correctly + // handles any overlap topology: side-by-side, corner overlap, + // landscape+portrait, and full mirror (which yields no exclusive area + // for the second output and therefore no labels there). + + // Count monitors and compute average monitor area for a consistent + // cell size across all regions. int64_t total_area = 0; int n = 0; struct overlay_surface *ov; @@ -49,42 +89,72 @@ void *tile_mode_enter(struct state *state, struct rect area) { return ms; } - int32_t avg_area = (int32_t)(total_area / n); - int sub_area_size = - max(avg_area / max_num_sub_areas, MIN_SUB_AREA_SIZE); - int cell_h = max((int)sqrt(sub_area_size / 2.), 1); - int cell_w = max((int)sqrt(sub_area_size * 2.), 1); + int32_t avg_area = (int32_t)(total_area / n); + int sub_area_size = max(avg_area / max_num_sub_areas, MIN_SUB_AREA_SIZE); + int cell_h = max((int)sqrt(sub_area_size / 2.), 1); + int cell_w = max((int)sqrt(sub_area_size * 2.), 1); - // Allocate region array (one entry per output with a known position). - ms->regions = malloc(n * sizeof(struct tile_region)); + // Each output can produce at most MAX_PENDING_RECTS exclusive + // sub-rectangles, so allocate that many slots per output. + ms->regions = malloc((size_t)n * MAX_PENDING_RECTS * sizeof(struct tile_region)); ms->num_regions = 0; int label_offset = 0; + // Full bounds of already-processed outputs, for subtraction. + struct rect *processed = malloc((size_t)n * sizeof(struct rect)); + int n_processed = 0; + wl_list_for_each (ov, &state->overlay_surfaces, link) { if (ov->output == NULL) continue; - struct output *o = ov->output; - struct tile_region *r = &ms->regions[ms->num_regions++]; - - r->area.x = o->x; - r->area.y = o->y; - r->area.w = o->width; - r->area.h = o->height; - - r->rows = max(o->height / cell_h, 1); - r->cols = max(o->width / cell_w, 1); + struct output *o = ov->output; + + // Ping-pong buffers: subtract each prior output's bounds from the + // current pending set to obtain this output's exclusive rectangles. + struct rect ping[MAX_PENDING_RECTS], pong[MAX_PENDING_RECTS]; + struct rect *cur = ping, *nxt = pong; + int n_cur = 1; + cur[0] = (struct rect){.x = o->x, .y = o->y, .w = o->width, .h = o->height}; + + for (int pi = 0; pi < n_processed && n_cur > 0; pi++) { + int n_nxt = 0; + for (int ri = 0; ri < n_cur; ri++) { + struct rect out4[4]; + int cnt = rect_subtract(cur[ri], processed[pi], out4); + for (int k = 0; k < cnt; k++) { + if (n_nxt < MAX_PENDING_RECTS) + nxt[n_nxt++] = out4[k]; + } + } + struct rect *tmp = cur; cur = nxt; nxt = tmp; + n_cur = n_nxt; + } - r->cell_h = o->height / r->rows; - r->cell_h_off = o->height % r->rows; - r->cell_w = o->width / r->cols; - r->cell_w_off = o->width % r->cols; + // Create one tile region per exclusive sub-rectangle. + for (int ri = 0; ri < n_cur; ri++) { + struct rect r_area = cur[ri]; + if (r_area.w <= 0 || r_area.h <= 0 || + ms->num_regions >= n * MAX_PENDING_RECTS) { + continue; + } + struct tile_region *r = &ms->regions[ms->num_regions++]; + r->area = r_area; + r->rows = max(r_area.h / cell_h, 1); + r->cols = max(r_area.w / cell_w, 1); + r->cell_h = r_area.h / r->rows; + r->cell_h_off = r_area.h % r->rows; + r->cell_w = r_area.w / r->cols; + r->cell_w_off = r_area.w % r->cols; + r->label_offset = label_offset; + r->num_labels = r->rows * r->cols; + label_offset += r->num_labels; + } - r->label_offset = label_offset; - r->num_labels = r->rows * r->cols; - label_offset += r->num_labels; + processed[n_processed++] = + (struct rect){.x = o->x, .y = o->y, .w = o->width, .h = o->height}; } - ms->label_selection = - label_selection_new(ms->label_symbols, label_offset); + free(processed); + ms->label_selection = label_selection_new(ms->label_symbols, label_offset); } else { // Single-output path: flat grid over the whole area. int32_t density_area = ms->area.w * ms->area.h; From a8e0460c48a18d4362f18f7fbded930cc6dd94b5 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 18:48:41 -0800 Subject: [PATCH 10/14] tile: clean up exclusive-region helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove accidental alignment spaces from MIN_SUB_AREA_SIZE define - Simplify rect_intersect: remove intermediate w/h variables that shadow struct field names; inline the ternary comparisons directly - Remove manual column-alignment padding from rect_subtract struct literals; no other struct literals in the codebase use this style - Update struct tile_region comment: "one region per monitor" was wrong after the exclusive-region approach — a single output can produce multiple tile_region structs when its bounds partially overlap a previously processed output Co-Authored-By: Claude Sonnet 4.5 --- src/mode_tile.c | 16 +++++++++------- src/state.h | 4 +++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/mode_tile.c b/src/mode_tile.c index d468eda..ec986f5 100644 --- a/src/mode_tile.c +++ b/src/mode_tile.c @@ -11,7 +11,7 @@ #include #include -#define MIN_SUB_AREA_SIZE (25 * 50) +#define MIN_SUB_AREA_SIZE (25 * 50) // Upper bound on pending sub-rectangles when computing one output's exclusive // area. In any real monitor layout this will never be reached. #define MAX_PENDING_RECTS 64 @@ -22,9 +22,11 @@ static struct rect rect_intersect(struct rect a, struct rect b) { int32_t y1 = max(a.y, b.y); int32_t x2 = min(a.x + a.w, b.x + b.w); int32_t y2 = min(a.y + a.h, b.y + b.h); - int32_t w = x2 - x1; - int32_t h = y2 - y1; - return (struct rect){.x = x1, .y = y1, .w = w > 0 ? w : 0, .h = h > 0 ? h : 0}; + return (struct rect){ + .x = x1, .y = y1, + .w = x2 > x1 ? x2 - x1 : 0, + .h = y2 > y1 ? y2 - y1 : 0, + }; } // Subtract rectangle b from rectangle a using a cross decomposition: @@ -39,13 +41,13 @@ static int rect_subtract(struct rect a, struct rect b, struct rect out[4]) { } int n = 0; if (i.x > a.x) - out[n++] = (struct rect){.x = a.x, .y = a.y, .w = i.x - a.x, .h = a.h}; + out[n++] = (struct rect){.x = a.x, .y = a.y, .w = i.x - a.x, .h = a.h}; if (i.x + i.w < a.x + a.w) out[n++] = (struct rect){.x = i.x + i.w, .y = a.y, .w = (a.x + a.w) - (i.x + i.w), .h = a.h}; if (i.y > a.y) - out[n++] = (struct rect){.x = i.x, .y = a.y, .w = i.w, .h = i.y - a.y}; + out[n++] = (struct rect){.x = i.x, .y = a.y, .w = i.w, .h = i.y - a.y}; if (i.y + i.h < a.y + a.h) - out[n++] = (struct rect){.x = i.x, .y = i.y + i.h, .w = i.w, .h = (a.y + a.h) - (i.y + i.h)}; + out[n++] = (struct rect){.x = i.x, .y = i.y + i.h, .w = i.w, .h = (a.y + a.h) - (i.y + i.h)}; return n; } diff --git a/src/state.h b/src/state.h index 6b61c17..1c1acb0 100644 --- a/src/state.h +++ b/src/state.h @@ -39,7 +39,9 @@ struct mode_interface; -// One selectable region per monitor in all-outputs mode. +// One exclusive sub-rectangle of an output in all-outputs mode. +// A single output may produce multiple tile_region structs if its bounds +// partially overlap with a previously processed output. struct tile_region { struct rect area; // position and size in global coordinates int rows; From 5fe4e1f4c6d9317518f24ea20928a49e1182174d Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 18:56:18 -0800 Subject: [PATCH 11/14] Fix -A CLI flag precedence and NULL deref in compute_initial_area - Push 'all_outputs=true' into cli_configs instead of directly assigning to state.config, so -A takes precedence over any all_outputs=false in a config file (cli_configs are applied after file load). - Guard overlay->output with a NULL check in compute_initial_area to avoid a deref before the surface.enter event fires. - Tighten render loop comment in tile_mode_render. --- src/main.c | 10 +++++++++- src/mode_tile.c | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index 1d4ec6d..cafd8b9 100644 --- a/src/main.c +++ b/src/main.c @@ -120,6 +120,7 @@ bool compute_initial_area(struct state *state, struct rect *initial_area) { struct overlay_surface *overlay; wl_list_for_each (overlay, &state->overlay_surfaces, link) { struct output *o = overlay->output; + if (o == NULL) continue; if (o->x < min_x) min_x = o->x; if (o->y < min_y) min_y = o->y; if (o->x + o->width > max_x) max_x = o->x + o->width; @@ -932,7 +933,14 @@ int main(int argc, char **argv) { break; case 'A': - state.config.general.all_outputs = true; + // Push as a cli_config so it is applied after the config file, + // giving the CLI flag precedence over any file setting. + if (num_cli_configs >= cli_configs_len) { + cli_configs_len += 10; + cli_configs = + realloc(cli_configs, cli_configs_len * sizeof(char *)); + } + cli_configs[num_cli_configs++] = "all_outputs=true"; break; case 'p': diff --git a/src/mode_tile.c b/src/mode_tile.c index ec986f5..0714ce3 100644 --- a/src/mode_tile.c +++ b/src/mode_tile.c @@ -358,7 +358,7 @@ void tile_mode_render(struct state *state, void *mode_state, cairo_t *cairo) { char label_unselected_str[label_str_max_len]; if (ms->regions != NULL) { - // Region-based rendering: iterate over each monitor's region. + // Render cells in each exclusive sub-region. for (int ri = 0; ri < ms->num_regions; ri++) { struct tile_region *r = &ms->regions[ri]; From bcbd07a4862d68281fd61721a99458582234e089 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Tue, 17 Feb 2026 18:57:56 -0800 Subject: [PATCH 12/14] Fix inaccurate overlap topology descriptions in README - Corner overlap: only the second monitor's area is split (into 2 strips, not 4); the first monitor always keeps its full area. - Landscape+portrait: the two statements described mutually exclusive orderings simultaneously; rewrite to make monitor-list-order dependency explicit. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e944af8..7755091 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,8 @@ This exclusive-region approach handles arbitrary overlap topologies correctly: | Layout | Behaviour | | ------ | --------- | | Side-by-side / stacked (no overlap) | One region per monitor, as expected. | -| Corner overlap | Each monitor's exclusive area is split into up to 4 rectangles; the shared corner belongs to whichever monitor is listed first by the compositor. | -| Landscape + portrait overlap | The portrait monitor's cells cover only the rows that extend below the landscape monitor; the landscape monitor's cells cover the columns outside the portrait monitor's width. | +| Corner overlap | The first monitor keeps its full area; the second monitor's exclusive area is 2 strips (the non-overlapping edges). The shared corner belongs to the first monitor. | +| Landscape + portrait overlap | The first-listed monitor keeps its full area. If landscape is first, the portrait monitor covers only the strip extending beyond the landscape area. If portrait is first, landscape gets left/right columns beside the portrait. | | Full mirror (same logical position) | The second monitor has no exclusive area and receives no labels. Its overlay surface shows only the background tint. | ### Cell density From 7ae209409c27e9bfeca52e57da2ed9f315531ba0 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Wed, 18 Feb 2026 09:14:11 -0800 Subject: [PATCH 13/14] Add all-outputs support to floating mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - query_screenshot now takes an explicit wl_output parameter instead of reading state->current_output, making it safe to call for any output. - For detect mode with --all-outputs: loop over all overlay surfaces, capture each output's screenshot, run detection, then shift output-local results by the output's global origin to produce global coordinates. These feed naturally into the existing per-surface cairo_translate(-output->x, -output->y) in send_frame_for_overlay. - For stdin mode with --all-outputs: no code change needed — areas from stdin are expected in global coordinates and the existing rendering path already handles them correctly. - Update README to reflect floating mode multi-monitor support. --- README.md | 2 +- src/mode_floating.c | 58 +++++++++++++++++++++++++++++++++++++++++++-- src/screencopy.c | 5 ++-- src/screencopy.h | 4 +++- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7755091..cda0482 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ bind=$mainMod,g,exec,hyprctl keyword cursor:inactive_timeout 0; hyprctl keyword By default, `wl-kbptr` shows its overlay only on the currently focused output. The `--all-outputs` / `-A` flag spans the overlay across all connected outputs simultaneously, so you don't need to focus the right display before invoking it. -> **Note:** Multi-monitor mode is currently implemented for **tile mode only**. Floating mode multi-monitor support is not yet available. +> **Note:** Multi-monitor mode is implemented for **tile mode** and **floating mode**. For floating mode with `mode_floating.source=detect`, targets are detected on each output independently and combined. For floating mode with stdin input, pass areas in global coordinates. ### Usage diff --git a/src/mode_floating.c b/src/mode_floating.c index 4d927d0..9a32be3 100644 --- a/src/mode_floating.c +++ b/src/mode_floating.c @@ -61,7 +61,8 @@ static void get_area_from_screenshot( area.h -= 2; area.w -= 2; - struct scrcpy_buffer *scrcpy_buffer = query_screenshot(state, area); + struct scrcpy_buffer *scrcpy_buffer = + query_screenshot(state, state->current_output->wl_output, area); enum wl_output_transform output_transform = state->current_output->transform; ms->num_areas = compute_target_from_img_buffer( @@ -72,6 +73,55 @@ static void get_area_from_screenshot( destroy_scrcpy_buffer(scrcpy_buffer); } +// Detect targets on all outputs and combine results into global coordinates. +static void get_areas_from_all_screenshots( + struct state *state, struct floating_mode_state *ms +) { + size_t areas_cap = 256; + struct rect *all_areas = malloc(sizeof(struct rect) * areas_cap); + int total = 0; + + struct overlay_surface *ov; + wl_list_for_each (ov, &state->overlay_surfaces, link) { + if (ov->output == NULL) continue; + struct output *o = ov->output; + + // Capture the full output, excluding a 1px border to avoid window frame + // edges interfering with contour detection. + struct rect region = { + .x = 1, .y = 1, .w = o->width - 2, .h = o->height - 2, + }; + + struct scrcpy_buffer *buf = + query_screenshot(state, o->wl_output, region); + if (buf == NULL) continue; + + struct rect *detected = NULL; + int n = compute_target_from_img_buffer( + buf->data, buf->height, buf->width, buf->stride, buf->format, + o->transform, region, &detected + ); + destroy_scrcpy_buffer(buf); + + // Shift output-local coordinates to global coordinates. + for (int i = 0; i < n; i++) { + if (total >= (int)areas_cap) { + areas_cap *= 2; + all_areas = realloc(all_areas, sizeof(struct rect) * areas_cap); + } + all_areas[total].x = detected[i].x + o->x; + all_areas[total].y = detected[i].y + o->y; + all_areas[total].w = detected[i].w; + all_areas[total].h = detected[i].h; + total++; + } + free(detected); + } + + ms->areas = all_areas; + ms->num_areas = total; +} + #endif void *floating_mode_enter(struct state *state, struct rect area) { @@ -93,7 +143,11 @@ void *floating_mode_enter(struct state *state, struct rect area) { break; case FLOATING_MODE_SOURCE_DETECT: #if OPENCV_ENABLED - get_area_from_screenshot(state, ms, area); + if (state->config.general.all_outputs) { + get_areas_from_all_screenshots(state, ms); + } else { + get_area_from_screenshot(state, ms, area); + } #else // This should not happen as the value is checked when loading the // configuration. diff --git a/src/screencopy.c b/src/screencopy.c index 35399a3..9e3629d 100644 --- a/src/screencopy.c +++ b/src/screencopy.c @@ -121,7 +121,7 @@ const struct zwlr_screencopy_frame_v1_listener screencopy_frame_listener = { }; struct scrcpy_buffer * -query_screenshot(struct state *state, struct rect region) { +query_screenshot(struct state *state, struct wl_output *wl_output, struct rect region) { struct scrcpy_state scrcpy_state; scrcpy_state.wl_shm = state->wl_shm; @@ -137,8 +137,7 @@ query_screenshot(struct state *state, struct rect region) { scrcpy_state.wl_screencopy_frame = zwlr_screencopy_manager_v1_capture_output_region( state->wl_screencopy_manager, false, - state->current_output->wl_output, region.x, region.y, region.w, - region.h + wl_output, region.x, region.y, region.w, region.h ); zwlr_screencopy_frame_v1_add_listener( scrcpy_state.wl_screencopy_frame, &screencopy_frame_listener, diff --git a/src/screencopy.h b/src/screencopy.h index 9136f60..e844dba 100644 --- a/src/screencopy.h +++ b/src/screencopy.h @@ -16,7 +16,9 @@ struct scrcpy_buffer { struct state; struct rect; -struct scrcpy_buffer *query_screenshot(struct state *state, struct rect region); +struct scrcpy_buffer *query_screenshot( + struct state *state, struct wl_output *wl_output, struct rect region +); void destroy_scrcpy_buffer(struct scrcpy_buffer *buf); From 2733324fbd9f0ae5182a683792520214158872f6 Mon Sep 17 00:00:00 2001 From: Jason Curtis Date: Wed, 18 Feb 2026 09:21:47 -0800 Subject: [PATCH 14/14] Fix config.example comment: all_outputs works for floating mode too --- config.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.example b/config.example index d8abccc..452faeb 100644 --- a/config.example +++ b/config.example @@ -7,7 +7,7 @@ home_row_keys= modes=tile,bisect cancellation_status_code=0 -# Span the overlay across all connected outputs simultaneously (tile mode only). +# Span the overlay across all connected outputs simultaneously (tile and floating modes). # Equivalent to the -A / --all-outputs command-line flag. all_outputs=false