From 850e50b9b70f2abb3a3782a611a86f2cb7cd4532 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:00:00 -0600 Subject: [PATCH 01/13] tree/container: add placeholder leaf scaffolding Add is_placeholder and swallows fields plus container_init_border_rects, used by upcoming append_layout support. No behaviour change. --- include/sway/tree/container.h | 8 ++++++++ sway/tree/container.c | 27 +++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/include/sway/tree/container.h b/include/sway/tree/container.h index d8780544d7..9b2571c1aa 100644 --- a/include/sway/tree/container.h +++ b/include/sway/tree/container.h @@ -141,11 +141,19 @@ struct sway_container { list_t *marks; // char * + // append_layout placeholder: view==NULL, swallows holds matchers for the + // view that should be installed here. swallows_json retains the original + // i3-shaped array so IPC can echo it verbatim for round-trip. + bool is_placeholder; + list_t *swallows; // struct criteria * + struct { struct wl_signal destroy; } events; }; +void container_init_border_rects(struct sway_container *c, bool *failed); + struct sway_container *container_create(struct sway_view *view); void container_destroy(struct sway_container *con); diff --git a/sway/tree/container.c b/sway/tree/container.c index 6880841bdc..c76d4f32d6 100644 --- a/sway/tree/container.c +++ b/sway/tree/container.c @@ -8,6 +8,7 @@ #include #include #include "sway/config.h" +#include "sway/criteria.h" #include "sway/desktop/transaction.h" #include "sway/input/input-manager.h" #include "sway/input/seat.h" @@ -43,6 +44,13 @@ static struct wlr_scene_rect *alloc_rect_node(struct wlr_scene_tree *parent, return rect; } +void container_init_border_rects(struct sway_container *c, bool *failed) { + c->border.top = alloc_rect_node(c->border.tree, failed); + c->border.bottom = alloc_rect_node(c->border.tree, failed); + c->border.left = alloc_rect_node(c->border.tree, failed); + c->border.right = alloc_rect_node(c->border.tree, failed); +} + struct sway_container *container_create(struct sway_view *view) { struct sway_container *c = calloc(1, sizeof(struct sway_container)); if (!c) { @@ -87,10 +95,7 @@ struct sway_container *container_create(struct sway_view *view) { if (view) { // only containers with views can have borders - c->border.top = alloc_rect_node(c->border.tree, &failed); - c->border.bottom = alloc_rect_node(c->border.tree, &failed); - c->border.left = alloc_rect_node(c->border.tree, &failed); - c->border.right = alloc_rect_node(c->border.tree, &failed); + container_init_border_rects(c, &failed); } if (!failed && !scene_descriptor_assign(&c->scene_tree->node, @@ -460,6 +465,13 @@ void container_destroy(struct sway_container *con) { list_free_items_and_destroy(con->marks); + if (con->swallows) { + for (int i = 0; i < con->swallows->length; ++i) { + criteria_destroy(con->swallows->items[i]); + } + list_free(con->swallows); + } + if (con->view && con->view->container == con) { con->view->container = NULL; if (con->view->destroying) { @@ -509,11 +521,18 @@ void container_reap_empty(struct sway_container *con) { if (con->view) { return; } + // Placeholders are intentionally view-less; do not reap them. + if (con->is_placeholder) { + return; + } struct sway_workspace *ws = con->pending.workspace; while (con) { if (con->pending.children->length) { return; } + if (con->is_placeholder) { + return; + } struct sway_container *parent = con->pending.parent; container_begin_destroy(con); con = parent; From 83d6fef24e8d46549e767ba1bd47ebc01eb65cdc Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:30:00 -0600 Subject: [PATCH 02/13] criteria: add view-only matcher for swallows The swallow walker matches against a view that has not yet been attached to a container. Factor the view-intrinsic checks out of criteria_matches_view and expose them as criteria_matches_view_unmapped. --- include/sway/criteria.h | 12 +++++++++ sway/criteria.c | 59 +++++++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/include/sway/criteria.h b/include/sway/criteria.h index fad278e021..96afd9a6f4 100644 --- a/include/sway/criteria.h +++ b/include/sway/criteria.h @@ -83,6 +83,18 @@ struct criteria *criteria_parse(char *raw, char **error); */ list_t *criteria_for_view(struct sway_view *view, enum criteria_type types); +/** + * Match a criteria against a view without requiring view->container to be + * set. Used by append_layout's swallow matching, which runs before a view + * is attached to a container in the tree. Only view-intrinsic fields are + * considered: title, shell, app_id, sandbox_*, tag, pid, and (when xwayland + * is enabled) class, instance, window_role, window_type, X11 id. Criteria + * fields that depend on container state (con_mark, con_id, floating, tiling, + * workspace, urgent) are ignored. + */ +bool criteria_matches_view_unmapped(struct criteria *criteria, + struct sway_view *view); + /** * Compile a list of containers matching the given criteria. */ diff --git a/sway/criteria.c b/sway/criteria.c index 6be6e70426..85478d1fe9 100644 --- a/sway/criteria.c +++ b/sway/criteria.c @@ -190,16 +190,9 @@ static bool criteria_matches_container(struct criteria *criteria, return true; } -static bool criteria_matches_view(struct criteria *criteria, - struct sway_view *view) { - struct sway_seat *seat = input_manager_current_seat(); - struct sway_container *focus = seat_get_focused_container(seat); - struct sway_view *focused = focus ? focus->view : NULL; - - if (!view->container) { - return false; - } - +// View-intrinsic checks; does not require view->container. +static bool match_view_intrinsic(struct criteria *criteria, + struct sway_view *view, struct sway_view *focused) { if (criteria->title) { const char *title = view_get_title(view); if (!title) { @@ -340,10 +333,6 @@ static bool criteria_matches_view(struct criteria *criteria, } } - if (!criteria_matches_container(criteria, view->container)) { - return false; - } - #if WLR_HAS_XWAYLAND if (criteria->id) { // X11 window ID uint32_t x11_window_id = view_get_x11_window_id(view); @@ -419,6 +408,42 @@ static bool criteria_matches_view(struct criteria *criteria, } #endif + if (criteria->pid) { + if (criteria->pid != view->pid) { + return false; + } + } + + return true; +} + +bool criteria_matches_view_unmapped(struct criteria *criteria, + struct sway_view *view) { + struct sway_seat *seat = input_manager_current_seat(); + struct sway_container *focus = seat_get_focused_container(seat); + struct sway_view *focused = focus ? focus->view : NULL; + + return match_view_intrinsic(criteria, view, focused); +} + +static bool criteria_matches_view(struct criteria *criteria, + struct sway_view *view) { + struct sway_seat *seat = input_manager_current_seat(); + struct sway_container *focus = seat_get_focused_container(seat); + struct sway_view *focused = focus ? focus->view : NULL; + + if (!view->container) { + return false; + } + + if (!match_view_intrinsic(criteria, view, focused)) { + return false; + } + + if (!criteria_matches_container(criteria, view->container)) { + return false; + } + if (criteria->floating) { if (!container_is_floating(view->container)) { return false; @@ -471,12 +496,6 @@ static bool criteria_matches_view(struct criteria *criteria, } } - if (criteria->pid) { - if (criteria->pid != view->pid) { - return false; - } - } - return true; } From 21358c7c0e4e98265ee50b42382623e2e5bfa610 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:42:00 -0600 Subject: [PATCH 03/13] tree: add layout JSON loader Parse i3-save-tree-style JSON into a detached subtree of placeholder containers and append to a workspace. Reuses criteria_parse for swallows. Tiling-only; floating_nodes are skipped with a debug log. --- include/sway/tree/load_layout.h | 34 +++ sway/meson.build | 1 + sway/tree/load_layout.c | 464 ++++++++++++++++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 include/sway/tree/load_layout.h create mode 100644 sway/tree/load_layout.c diff --git a/include/sway/tree/load_layout.h b/include/sway/tree/load_layout.h new file mode 100644 index 0000000000..2a267b127f --- /dev/null +++ b/include/sway/tree/load_layout.h @@ -0,0 +1,34 @@ +#ifndef _SWAY_LOAD_LAYOUT_H +#define _SWAY_LOAD_LAYOUT_H + +#include + +struct sway_workspace; +struct sway_view; +struct sway_container; + +/** + * Append the container tree described by the JSON file at `path` to the given + * workspace. The file may be either strict JSON (a single object or array) or + * the i3-save-tree concatenated-object form (}\n{ between siblings). On error + * leaves the workspace unmodified, returns false, and writes an allocated + * error string to *error_out. Caller frees the error string. + * + * Currently tiling-only. Top-level `floating_nodes` (and any nested + * floating_nodes) are skipped with a debug log. + */ +bool load_layout_from_file(struct sway_workspace *ws, const char *path, + char **error_out); + +/** + * Walk the tree looking for a placeholder container (is_placeholder == true) + * whose swallows list contains a criteria that matches the given view. Walks + * tiling lists only, depth-first, document order. Returns the first match, or + * NULL if none. + * + * Used by view_map to decide whether an incoming view should be installed + * into a pre-existing placeholder slot rather than a freshly created one. + */ +struct sway_container *find_swallow_match(struct sway_view *view); + +#endif diff --git a/sway/meson.build b/sway/meson.build index cb03a4d288..31c7b2fd73 100644 --- a/sway/meson.build +++ b/sway/meson.build @@ -211,6 +211,7 @@ sway_sources = files( 'tree/arrange.c', 'tree/container.c', + 'tree/load_layout.c', 'tree/node.c', 'tree/root.c', 'tree/view.c', diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c new file mode 100644 index 0000000000..a86d1c3dd4 --- /dev/null +++ b/sway/tree/load_layout.c @@ -0,0 +1,464 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "list.h" +#include "log.h" +#include "stringop.h" +#include "sway/criteria.h" +#include "sway/desktop/transaction.h" +#include "sway/tree/arrange.h" +#include "sway/tree/container.h" +#include "sway/tree/load_layout.h" +#include "sway/tree/view.h" +#include "sway/tree/workspace.h" + +static char *slurp_file(const char *path, char **error_out) { + FILE *f = fopen(path, "rb"); + if (!f) { + *error_out = format_str("append_layout: cannot open %s: %s", + path, strerror(errno)); + return NULL; + } + if (fseek(f, 0, SEEK_END) != 0) { + *error_out = format_str("append_layout: seek failed on %s", path); + fclose(f); + return NULL; + } + long size = ftell(f); + if (size < 0) { + *error_out = format_str("append_layout: ftell failed on %s", path); + fclose(f); + return NULL; + } + rewind(f); + char *buf = malloc(size + 1); + if (!buf) { + *error_out = format_str("append_layout: out of memory"); + fclose(f); + return NULL; + } + size_t n = fread(buf, 1, size, f); + fclose(f); + if ((long)n != size) { + free(buf); + *error_out = format_str("append_layout: short read on %s", path); + return NULL; + } + buf[size] = '\0'; + return buf; +} + +// i3-save-tree emits a sequence of top-level objects separated by `}\n{` +// rather than wrapping them in an array. Wrap into a strict JSON array. +// Same string-literal caveat as i3's own loader. +static char *preprocess_i3_concat(char *buf) { + const char *p = buf; + while (*p && isspace((unsigned char)*p)) { + p++; + } + if (*p == '[' || *p != '{') { + return buf; + } + + size_t in_len = strlen(buf); + size_t cap = in_len + 16; + char *out = malloc(cap + 1); + if (!out) { + return buf; + } + size_t pos = 0; + out[pos++] = '['; + + for (size_t i = 0; i < in_len; i++) { + out[pos++] = buf[i]; + if (pos + 4 >= cap) { + cap = cap * 2 + 16; + char *grown = realloc(out, cap + 1); + if (!grown) { + free(out); + return buf; + } + out = grown; + } + if (buf[i] == '}') { + size_t j = i + 1; + while (j < in_len && isspace((unsigned char)buf[j])) { + j++; + } + if (j < in_len && buf[j] == '{') { + out[pos++] = ','; + } + } + } + out[pos++] = ']'; + out[pos] = '\0'; + free(buf); + return out; +} + +static enum sway_container_layout parse_layout_name(const char *s) { + if (!s) { + return L_NONE; + } + if (strcasecmp(s, "splith") == 0) { + return L_HORIZ; + } + if (strcasecmp(s, "splitv") == 0) { + return L_VERT; + } + if (strcasecmp(s, "tabbed") == 0) { + return L_TABBED; + } + // i3-save-tree emits "stacked", sway's `layout` command spells it + // "stacking"; accept both. + if (strcasecmp(s, "stacked") == 0 || strcasecmp(s, "stacking") == 0) { + return L_STACKED; + } + return L_NONE; +} + +static enum sway_container_border parse_border_name(const char *s) { + if (!s) { + return B_NORMAL; + } + if (strcasecmp(s, "none") == 0) { + return B_NONE; + } + if (strcasecmp(s, "pixel") == 0) { + return B_PIXEL; + } + if (strcasecmp(s, "csd") == 0) { + return B_CSD; + } + return B_NORMAL; +} + +// Append a key="value" fragment to a malloc'd, null-terminated buffer. The +// value is the raw regex from the swallows entry; we trust json-c to give us +// a NUL-terminated string and we do NOT escape internal quotes, i3-save-tree +// already escapes them in its output and hand-written layouts must follow the +// same rule. +static bool append_key_value(char **buf, const char *key, const char *value) { + size_t old = *buf ? strlen(*buf) : 0; + size_t add = strlen(key) + strlen(value) + 5; // ` k="v"` + char *grown = realloc(*buf, old + add + 1); + if (!grown) { + return false; + } + *buf = grown; + int written = snprintf(grown + old, add + 1, "%s%s=\"%s\"", + old ? " " : "", key, value); + if (written < 0) { + return false; + } + return true; +} + +// app_id is a sway extension over i3's swallows schema; machine is ignored. +static struct criteria *build_swallow_criteria(struct json_object *entry, + char **error_out) { + if (!json_object_is_type(entry, json_type_object)) { + *error_out = format_str("append_layout: swallows entry is not an object"); + return NULL; + } + static const char *keys[] = { + "class", "instance", "title", "window_role", "window_type", "app_id", + NULL, + }; + char *body = NULL; + for (int i = 0; keys[i]; i++) { + struct json_object *v; + if (!json_object_object_get_ex(entry, keys[i], &v)) { + continue; + } + if (!json_object_is_type(v, json_type_string)) { + free(body); + *error_out = format_str("append_layout: swallows.%s is not a string", + keys[i]); + return NULL; + } + if (!append_key_value(&body, keys[i], json_object_get_string(v))) { + free(body); + *error_out = format_str("append_layout: out of memory"); + return NULL; + } + } + struct json_object *machine; + if (json_object_object_get_ex(entry, "machine", &machine)) { + sway_log(SWAY_DEBUG, + "append_layout: ignoring 'machine' key in swallows entry"); + } + if (!body) { + free(body); + *error_out = format_str("append_layout: empty swallows entry"); + return NULL; + } + size_t body_len = strlen(body); + char *raw = malloc(body_len + 3); + if (!raw) { + free(body); + *error_out = format_str("append_layout: out of memory"); + return NULL; + } + raw[0] = '['; + memcpy(raw + 1, body, body_len); + raw[body_len + 1] = ']'; + raw[body_len + 2] = '\0'; + free(body); + + char *parse_err = NULL; + struct criteria *c = criteria_parse(raw, &parse_err); + free(raw); + if (!c) { + *error_out = format_str("append_layout: invalid swallows pattern: %s", + parse_err ? parse_err : "(no detail)"); + free(parse_err); + return NULL; + } + return c; +} + +static list_t *parse_swallows(struct json_object *arr, char **error_out) { + if (!json_object_is_type(arr, json_type_array)) { + *error_out = format_str("append_layout: swallows is not an array"); + return NULL; + } + list_t *out = create_list(); + size_t n = json_object_array_length(arr); + for (size_t i = 0; i < n; i++) { + struct json_object *entry = json_object_array_get_idx(arr, i); + struct criteria *c = build_swallow_criteria(entry, error_out); + if (!c) { + for (int j = 0; j < out->length; j++) { + criteria_destroy(out->items[j]); + } + list_free(out); + return NULL; + } + list_add(out, c); + } + return out; +} + +// Tear down a parsed subtree that we never attached to a workspace. +static void free_transient_subtree(struct sway_container *con) { + if (!con) { + return; + } + if (con->pending.children) { + while (con->pending.children->length) { + struct sway_container *child = + con->pending.children->items[0]; + free_transient_subtree(child); + } + } + container_begin_destroy(con); +} + +static struct sway_container *build_node(struct json_object *obj, + char **error_out); + +static struct sway_container *build_node(struct json_object *obj, + char **error_out) { + if (!json_object_is_type(obj, json_type_object)) { + *error_out = format_str("append_layout: node is not an object"); + return NULL; + } + + struct sway_container *c = container_create(NULL); + if (!c) { + *error_out = format_str("append_layout: container_create failed"); + return NULL; + } + + struct json_object *layout_v; + if (json_object_object_get_ex(obj, "layout", &layout_v) && + json_object_is_type(layout_v, json_type_string)) { + c->pending.layout = parse_layout_name(json_object_get_string(layout_v)); + } + + struct json_object *border_v; + if (json_object_object_get_ex(obj, "border", &border_v) && + json_object_is_type(border_v, json_type_string)) { + c->pending.border = parse_border_name(json_object_get_string(border_v)); + } else { + c->pending.border = B_NORMAL; + } + + struct json_object *bw_v; + if (json_object_object_get_ex(obj, "current_border_width", &bw_v) && + json_object_is_type(bw_v, json_type_int)) { + c->pending.border_thickness = json_object_get_int(bw_v); + } + + struct json_object *percent_v; + if (json_object_object_get_ex(obj, "percent", &percent_v)) { + // Normalised by the next arrange pass. + double p = json_object_get_double(percent_v); + c->width_fraction = p; + c->height_fraction = p; + } + + struct json_object *name_v; + if (json_object_object_get_ex(obj, "name", &name_v) && + json_object_is_type(name_v, json_type_string)) { + const char *s = json_object_get_string(name_v); + free(c->title); + c->title = strdup(s ? s : ""); + } + + struct json_object *marks_v; + if (json_object_object_get_ex(obj, "marks", &marks_v) && + json_object_is_type(marks_v, json_type_array)) { + size_t n = json_object_array_length(marks_v); + for (size_t i = 0; i < n; i++) { + struct json_object *m = json_object_array_get_idx(marks_v, i); + if (json_object_is_type(m, json_type_string)) { + container_add_mark(c, (char *)json_object_get_string(m)); + } + } + } + + struct json_object *floating_v; + if (json_object_object_get_ex(obj, "floating_nodes", &floating_v) && + json_object_is_type(floating_v, json_type_array) && + json_object_array_length(floating_v) > 0) { + sway_log(SWAY_DEBUG, "append_layout: skipping floating_nodes " + "(tiling-only support in this release)"); + } + + struct json_object *nodes_v; + bool has_children = json_object_object_get_ex(obj, "nodes", &nodes_v) && + json_object_is_type(nodes_v, json_type_array) && + json_object_array_length(nodes_v) > 0; + + struct json_object *swallows_v; + bool has_swallows = json_object_object_get_ex(obj, "swallows", &swallows_v); + + if (has_children) { + // i3 ignores swallows on non-leaves; mirror that. + size_t n = json_object_array_length(nodes_v); + for (size_t i = 0; i < n; i++) { + struct json_object *child_obj = + json_object_array_get_idx(nodes_v, i); + struct sway_container *child = build_node(child_obj, error_out); + if (!child) { + free_transient_subtree(c); + return NULL; + } + container_add_child(c, child); + } + } else if (has_swallows) { + list_t *sw = parse_swallows(swallows_v, error_out); + if (!sw) { + free_transient_subtree(c); + return NULL; + } + c->is_placeholder = true; + c->swallows = sw; + } else { + // Leaf without swallows is an empty split with no children. i3 does + // not produce these; treat as an error to avoid silently leaving + // orphaned containers. + *error_out = format_str("append_layout: node has neither nodes nor " + "swallows"); + free_transient_subtree(c); + return NULL; + } + + return c; +} + +bool load_layout_from_file(struct sway_workspace *ws, const char *path, + char **error_out) { + if (!ws) { + *error_out = format_str("append_layout: no target workspace"); + return false; + } + + char *buf = slurp_file(path, error_out); + if (!buf) { + return false; + } + buf = preprocess_i3_concat(buf); + + struct json_tokener *tok = json_tokener_new(); + if (!tok) { + free(buf); + *error_out = format_str("append_layout: json_tokener_new failed"); + return false; + } + struct json_object *root_obj = json_tokener_parse_ex(tok, buf, strlen(buf)); + enum json_tokener_error err = json_tokener_get_error(tok); + json_tokener_free(tok); + free(buf); + if (!root_obj || err != json_tokener_success) { + *error_out = format_str("append_layout: json parse error: %s", + json_tokener_error_desc(err)); + if (root_obj) { + json_object_put(root_obj); + } + return false; + } + + // All-or-nothing: build the whole tree before attaching anything. + list_t *children = create_list(); + bool ok = true; + if (json_object_is_type(root_obj, json_type_array)) { + size_t n = json_object_array_length(root_obj); + for (size_t i = 0; i < n; i++) { + struct json_object *entry = + json_object_array_get_idx(root_obj, i); + struct sway_container *child = build_node(entry, error_out); + if (!child) { + ok = false; + break; + } + list_add(children, child); + } + } else if (json_object_is_type(root_obj, json_type_object)) { + struct sway_container *child = build_node(root_obj, error_out); + if (!child) { + ok = false; + } else { + list_add(children, child); + } + } else { + *error_out = format_str("append_layout: unexpected JSON root type"); + ok = false; + } + json_object_put(root_obj); + + if (!ok) { + for (int i = 0; i < children->length; i++) { + free_transient_subtree(children->items[i]); + } + list_free(children); + return false; + } + + for (int i = 0; i < children->length; i++) { + workspace_add_tiling(ws, children->items[i]); + } + list_free(children); + + arrange_workspace(ws); + transaction_commit_dirty(); + return true; +} + +struct sway_container *find_swallow_match(struct sway_view *view) { + // Recursive depth-first walker. Returns NULL if no match. + struct sway_container *match = NULL; + (void)view; + (void)match; + // Implementation lives in the swallow-on-map commit. Keeping the + // declaration here makes the loader self-contained for now; until the + // hook lands, placeholders simply render as empty bordered slots. + return NULL; +} From cdb955f6994a4b395e0614060023dc4afe35fa56 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:15:00 -0600 Subject: [PATCH 04/13] commands: implement cmd_append_layout Wires the JSON loader to a config/runtime command. Defers at config-read time so workspaces exist when the layout is applied. --- include/sway/commands.h | 1 + sway/commands.c | 1 + sway/commands/append_layout.c | 47 +++++++++++++++++++++++++++++++++++ sway/meson.build | 1 + 4 files changed, 50 insertions(+) create mode 100644 sway/commands/append_layout.c diff --git a/include/sway/commands.h b/include/sway/commands.h index 389c382eb2..495a8c8972 100644 --- a/include/sway/commands.h +++ b/include/sway/commands.h @@ -105,6 +105,7 @@ sway_cmd cmd_exec_validate; sway_cmd cmd_exec_process; sway_cmd cmd_allow_tearing; +sway_cmd cmd_append_layout; sway_cmd cmd_assign; sway_cmd cmd_bar; sway_cmd cmd_bindcode; diff --git a/sway/commands.c b/sway/commands.c index c2c12ee655..c4bab8e049 100644 --- a/sway/commands.c +++ b/sway/commands.c @@ -42,6 +42,7 @@ struct cmd_results *checkarg(int argc, const char *name, enum expected_args type /* Keep alphabetized */ static const struct cmd_handler handlers[] = { + { "append_layout", cmd_append_layout }, { "assign", cmd_assign }, { "bar", cmd_bar }, { "bindcode", cmd_bindcode }, diff --git a/sway/commands/append_layout.c b/sway/commands/append_layout.c new file mode 100644 index 0000000000..eae8bff934 --- /dev/null +++ b/sway/commands/append_layout.c @@ -0,0 +1,47 @@ +#include "log.h" +#include "stringop.h" +#include "sway/commands.h" +#include "sway/config.h" +#include "sway/input/input-manager.h" +#include "sway/input/seat.h" +#include "sway/tree/load_layout.h" +#include "sway/tree/workspace.h" + +struct cmd_results *cmd_append_layout(int argc, char **argv) { + struct cmd_results *error = NULL; + if ((error = checkarg(argc, "append_layout", EXPECTED_AT_LEAST, 1))) { + return error; + } + + if (config->reading || !config->active) { + return cmd_results_new(CMD_DEFER, NULL); + } + + struct sway_seat *seat = input_manager_current_seat(); + struct sway_workspace *ws = seat_get_focused_workspace(seat); + if (!ws) { + return cmd_results_new(CMD_FAILURE, + "append_layout: no focused workspace"); + } + + char *path = join_args(argv, argc); + if (!path) { + return cmd_results_new(CMD_FAILURE, "append_layout: out of memory"); + } + if (!expand_path(&path)) { + free(path); + return cmd_results_new(CMD_FAILURE, + "append_layout: failed to expand path"); + } + + char *err = NULL; + bool ok = load_layout_from_file(ws, path, &err); + free(path); + if (!ok) { + struct cmd_results *res = cmd_results_new(CMD_FAILURE, "%s", + err ? err : "append_layout: unknown error"); + free(err); + return res; + } + return cmd_results_new(CMD_SUCCESS, NULL); +} diff --git a/sway/meson.build b/sway/meson.build index 31c7b2fd73..90715acece 100644 --- a/sway/meson.build +++ b/sway/meson.build @@ -43,6 +43,7 @@ sway_sources = files( 'config/input.c', 'commands/allow_tearing.c', + 'commands/append_layout.c', 'commands/assign.c', 'commands/bar.c', 'commands/bind.c', From 40bb60a91d690a3667a1cd4249d4430bc30478f6 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:18:00 -0600 Subject: [PATCH 05/13] tree/view: swallow matching views into placeholders Before container_create in view_map, look up an unfilled placeholder whose swallows match. On hit, install the view in place and skip the workspace/sibling placement path. Border config is preserved; for_window still runs post-placement. --- sway/tree/load_layout.c | 56 ++++++++++++++++--- sway/tree/view.c | 117 +++++++++++++++++++++++++++++----------- 2 files changed, 136 insertions(+), 37 deletions(-) diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c index a86d1c3dd4..247c4e01b0 100644 --- a/sway/tree/load_layout.c +++ b/sway/tree/load_layout.c @@ -11,9 +11,11 @@ #include "stringop.h" #include "sway/criteria.h" #include "sway/desktop/transaction.h" +#include "sway/output.h" #include "sway/tree/arrange.h" #include "sway/tree/container.h" #include "sway/tree/load_layout.h" +#include "sway/tree/root.h" #include "sway/tree/view.h" #include "sway/tree/workspace.h" @@ -452,13 +454,53 @@ bool load_layout_from_file(struct sway_workspace *ws, const char *path, return true; } +static bool placeholder_matches_view(struct sway_container *placeholder, + struct sway_view *view) { + if (!placeholder->is_placeholder || !placeholder->swallows) { + return false; + } + for (int i = 0; i < placeholder->swallows->length; i++) { + struct criteria *c = placeholder->swallows->items[i]; + if (criteria_matches_view_unmapped(c, view)) { + return true; + } + } + return false; +} + +static struct sway_container *search_swallow(struct sway_container *con, + struct sway_view *view) { + if (placeholder_matches_view(con, view)) { + return con; + } + if (con->pending.children) { + for (int i = 0; i < con->pending.children->length; i++) { + struct sway_container *child = con->pending.children->items[i]; + struct sway_container *match = search_swallow(child, view); + if (match) { + return match; + } + } + } + return NULL; +} + struct sway_container *find_swallow_match(struct sway_view *view) { - // Recursive depth-first walker. Returns NULL if no match. - struct sway_container *match = NULL; - (void)view; - (void)match; - // Implementation lives in the swallow-on-map commit. Keeping the - // declaration here makes the loader self-contained for now; until the - // hook lands, placeholders simply render as empty bordered slots. + if (!view) { + return NULL; + } + for (int o = 0; o < root->outputs->length; o++) { + struct sway_output *output = root->outputs->items[o]; + for (int w = 0; w < output->workspaces->length; w++) { + struct sway_workspace *ws = output->workspaces->items[w]; + for (int t = 0; t < ws->tiling->length; t++) { + struct sway_container *con = ws->tiling->items[t]; + struct sway_container *match = search_swallow(con, view); + if (match) { + return match; + } + } + } + } return NULL; } diff --git a/sway/tree/view.c b/sway/tree/view.c index 83b4972b13..b1e3c6daa3 100644 --- a/sway/tree/view.c +++ b/sway/tree/view.c @@ -32,6 +32,7 @@ #include "sway/sway_text_node.h" #include "sway/tree/arrange.h" #include "sway/tree/container.h" +#include "sway/tree/load_layout.h" #include "sway/tree/view.h" #include "sway/tree/workspace.h" #include "sway/config.h" @@ -809,6 +810,41 @@ static void handle_foreign_destroy( wl_list_remove(&view->foreign_destroy.link); } +// Promote a placeholder into a view-backed container in place, fixing up +// the bits container_create normally branches on `view` for: border rects +// (only allocated for view-backed) and children lists (only allocated for +// view-less). The transaction reparents view->scene_tree into content_tree. +static void promote_placeholder(struct sway_container *placeholder, + struct sway_view *view) { + if (placeholder->swallows) { + for (int i = 0; i < placeholder->swallows->length; i++) { + criteria_destroy(placeholder->swallows->items[i]); + } + list_free(placeholder->swallows); + placeholder->swallows = NULL; + } + placeholder->is_placeholder = false; + + if (placeholder->pending.children) { + list_free(placeholder->pending.children); + placeholder->pending.children = NULL; + } + if (placeholder->current.children) { + list_free(placeholder->current.children); + placeholder->current.children = NULL; + } + + bool failed = false; + container_init_border_rects(placeholder, &failed); + if (failed) { + sway_log(SWAY_ERROR, "promote_placeholder: border rect alloc failed"); + } + + placeholder->view = view; + view->container = placeholder; + node_set_dirty(&placeholder->node); +} + void view_map(struct sway_view *view, struct wlr_surface *wlr_surface, bool fullscreen, struct wlr_output *fullscreen_output, bool decoration) { @@ -817,7 +853,15 @@ void view_map(struct sway_view *view, struct wlr_surface *wlr_surface, } view->surface = wlr_surface; view_populate_pid(view); - view->container = container_create(view); + + // On a swallow match, the placeholder is already in the tree; skip the + // workspace/target-sibling placement path below. + struct sway_container *placeholder = find_swallow_match(view); + if (placeholder) { + promote_placeholder(placeholder, view); + } else { + view->container = container_create(view); + } if (view->ctx == NULL) { struct launcher_ctx *ctx = launcher_ctx_find_pid(view->pid); @@ -828,14 +872,19 @@ void view_map(struct sway_view *view, struct wlr_surface *wlr_surface, // If there is a request to be opened fullscreen on a specific output, try // to honor that request. Otherwise, fallback to assigns, pid mappings, - // focused workspace, etc + // focused workspace, etc. A swallowed view stays on the placeholder's + // workspace. struct sway_workspace *ws = NULL; - if (fullscreen_output && fullscreen_output->data) { - struct sway_output *output = fullscreen_output->data; - ws = output_get_active_workspace(output); - } - if (!ws) { - ws = select_workspace(view); + if (placeholder) { + ws = view->container->pending.workspace; + } else { + if (fullscreen_output && fullscreen_output->data) { + struct sway_output *output = fullscreen_output->data; + ws = output_get_active_workspace(output); + } + if (!ws) { + ws = select_workspace(view); + } } if (ws && ws->output) { @@ -846,28 +895,30 @@ void view_map(struct sway_view *view, struct wlr_surface *wlr_surface, wlr_surface_set_preferred_buffer_scale(wlr_surface, ceil(scale)); } - struct sway_seat *seat = input_manager_current_seat(); - struct sway_node *node = - seat_get_focus_inactive(seat, ws ? &ws->node : &root->node); struct sway_container *target_sibling = NULL; - if (node && node->type == N_CONTAINER) { - if (container_is_floating(node->sway_container)) { - // If we're about to launch the view into the floating container, then - // launch it as a tiled view instead. - if (ws) { - target_sibling = seat_get_focus_inactive_tiling(seat, ws); - if (target_sibling) { - struct sway_container *con = - seat_get_focus_inactive_view(seat, &target_sibling->node); - if (con) { - target_sibling = con; + if (!placeholder) { + struct sway_seat *seat = input_manager_current_seat(); + struct sway_node *node = + seat_get_focus_inactive(seat, ws ? &ws->node : &root->node); + if (node && node->type == N_CONTAINER) { + if (container_is_floating(node->sway_container)) { + // If we're about to launch the view into the floating container, then + // launch it as a tiled view instead. + if (ws) { + target_sibling = seat_get_focus_inactive_tiling(seat, ws); + if (target_sibling) { + struct sway_container *con = + seat_get_focus_inactive_view(seat, &target_sibling->node); + if (con) { + target_sibling = con; + } } + } else { + ws = seat_get_last_known_workspace(seat); } } else { - ws = seat_get_last_known_workspace(seat); + target_sibling = node->sway_container; } - } else { - target_sibling = node->sway_container; } } @@ -895,10 +946,12 @@ void view_map(struct sway_view *view, struct wlr_surface *wlr_surface, &view->foreign_destroy); struct sway_container *container = view->container; - if (target_sibling) { - container_add_sibling(target_sibling, container, 1); - } else if (ws) { - container = workspace_add_tiling(ws, container); + if (!placeholder) { + if (target_sibling) { + container_add_sibling(target_sibling, container, 1); + } else if (ws) { + container = workspace_add_tiling(ws, container); + } } ipc_event_window(view->container, "new"); @@ -906,7 +959,11 @@ void view_map(struct sway_view *view, struct wlr_surface *wlr_surface, view_update_csd_from_client(view, decoration); } - if (view->impl->wants_floating && view->impl->wants_floating(view)) { + if (placeholder) { + // Keep the JSON-loaded border; ignore wants_floating since the user + // explicitly placed a tiling slot. + view_set_tiled(view, true); + } else if (view->impl->wants_floating && view->impl->wants_floating(view)) { view->container->pending.border = config->floating_border; view->container->pending.border_thickness = config->floating_border_thickness; container_set_floating(view->container, true); From cc4ba936e95d611bce3b67c5d926feb4f8e73136 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Fri, 1 May 2026 17:50:00 -0600 Subject: [PATCH 06/13] ipc-json,swaysavetree: round-trip placeholder swallows ipc_json_describe_container emits the original swallows JSON for unfilled placeholders. New sway-save-tree binary dumps a workspace tiling tree as append_layout-compatible JSON, synthesising swallows from app_id / class / instance. --- include/sway/tree/container.h | 2 + meson.build | 1 + sway/ipc-json.c | 5 + sway/tree/container.c | 4 + sway/tree/load_layout.c | 2 + sway/tree/view.c | 5 + swaysavetree/main.c | 292 ++++++++++++++++++++++++++++++++++ swaysavetree/meson.build | 8 + 8 files changed, 319 insertions(+) create mode 100644 swaysavetree/main.c create mode 100644 swaysavetree/meson.build diff --git a/include/sway/tree/container.h b/include/sway/tree/container.h index 9b2571c1aa..1a38d678b1 100644 --- a/include/sway/tree/container.h +++ b/include/sway/tree/container.h @@ -9,6 +9,7 @@ struct sway_view; struct sway_seat; +struct json_object; enum sway_container_layout { L_NONE, @@ -146,6 +147,7 @@ struct sway_container { // i3-shaped array so IPC can echo it verbatim for round-trip. bool is_placeholder; list_t *swallows; // struct criteria * + struct json_object *swallows_json; struct { struct wl_signal destroy; diff --git a/meson.build b/meson.build index 17d65c334c..98afc98fdf 100644 --- a/meson.build +++ b/meson.build @@ -195,6 +195,7 @@ subdir('protocols') subdir('common') subdir('sway') subdir('swaymsg') +subdir('swaysavetree') if get_option('swaybar') or get_option('swaynag') subdir('client') diff --git a/sway/ipc-json.c b/sway/ipc-json.c index 3b69ad3840..e6438304e7 100644 --- a/sway/ipc-json.c +++ b/sway/ipc-json.c @@ -776,6 +776,11 @@ static void ipc_json_describe_container(struct sway_container *c, json_object *o if (c->view) { ipc_json_describe_view(c, object); } + + if (c->is_placeholder && c->swallows_json) { + json_object_object_add(object, "swallows", + json_object_get(c->swallows_json)); + } } struct focus_inactive_data { diff --git a/sway/tree/container.c b/sway/tree/container.c index c76d4f32d6..b63aaebdcf 100644 --- a/sway/tree/container.c +++ b/sway/tree/container.c @@ -7,6 +7,7 @@ #include #include #include +#include #include "sway/config.h" #include "sway/criteria.h" #include "sway/desktop/transaction.h" @@ -471,6 +472,9 @@ void container_destroy(struct sway_container *con) { } list_free(con->swallows); } + if (con->swallows_json) { + json_object_put(con->swallows_json); + } if (con->view && con->view->container == con) { con->view->container = NULL; diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c index 247c4e01b0..8a9493823a 100644 --- a/sway/tree/load_layout.c +++ b/sway/tree/load_layout.c @@ -363,6 +363,8 @@ static struct sway_container *build_node(struct json_object *obj, } c->is_placeholder = true; c->swallows = sw; + // Retained so IPC can echo it verbatim for round-trip. + c->swallows_json = json_object_get(swallows_v); } else { // Leaf without swallows is an empty split with no children. i3 does // not produce these; treat as an error to avoid silently leaving diff --git a/sway/tree/view.c b/sway/tree/view.c index b1e3c6daa3..8cc019bca5 100644 --- a/sway/tree/view.c +++ b/sway/tree/view.c @@ -16,6 +16,7 @@ #if WLR_HAS_XWAYLAND #include #endif +#include #include "list.h" #include "log.h" #include "sway/criteria.h" @@ -823,6 +824,10 @@ static void promote_placeholder(struct sway_container *placeholder, list_free(placeholder->swallows); placeholder->swallows = NULL; } + if (placeholder->swallows_json) { + json_object_put(placeholder->swallows_json); + placeholder->swallows_json = NULL; + } placeholder->is_placeholder = false; if (placeholder->pending.children) { diff --git a/swaysavetree/main.c b/swaysavetree/main.c new file mode 100644 index 0000000000..da64cce69a --- /dev/null +++ b/swaysavetree/main.c @@ -0,0 +1,292 @@ +// sway-save-tree: dump a workspace's tiling tree as JSON for `swaymsg +// append_layout`. Counterpart to i3-save-tree(1). + +#include +#include +#include +#include +#include +#include +#include +#include +#include "ipc-client.h" +#include "ipc.h" +#include "log.h" + +static void usage(void) { + fprintf(stderr, + "Usage: sway-save-tree --workspace \n" + "\n" + "Dump the tiling tree of the named workspace as JSON suitable for\n" + "`swaymsg append_layout`. Output is sent to stdout.\n" + "\n" + "Floating windows are skipped in this release.\n"); +} + +// Anchored, metachar-escaped copy so swallows match the value literally. +static char *anchored_regex(const char *s) { + if (!s) { + return NULL; + } + size_t n = strlen(s); + char *out = malloc(n * 2 + 3); + if (!out) { + return NULL; + } + size_t pos = 0; + out[pos++] = '^'; + for (size_t i = 0; i < n; i++) { + char c = s[i]; + if (strchr(".\\+*?()[]{}|^$", c)) { + out[pos++] = '\\'; + } + out[pos++] = c; + } + out[pos++] = '$'; + out[pos] = '\0'; + return out; +} + +// Returns NULL if the view has nothing identifiable; caller drops the leaf. +static struct json_object *synth_swallows(struct json_object *view_obj) { + struct json_object *app_id = NULL, *wp = NULL; + const char *class = NULL, *instance = NULL; + + json_object_object_get_ex(view_obj, "app_id", &app_id); + + if (json_object_object_get_ex(view_obj, "window_properties", &wp)) { + struct json_object *cls, *inst; + if (json_object_object_get_ex(wp, "class", &cls)) { + class = json_object_get_string(cls); + } + if (json_object_object_get_ex(wp, "instance", &inst)) { + instance = json_object_get_string(inst); + } + } + + struct json_object *entry = json_object_new_object(); + bool added = false; + + if (app_id && json_object_get_type(app_id) == json_type_string) { + const char *s = json_object_get_string(app_id); + if (s && *s) { + char *re = anchored_regex(s); + if (re) { + json_object_object_add(entry, "app_id", + json_object_new_string(re)); + free(re); + added = true; + } + } + } + if (class) { + char *re = anchored_regex(class); + if (re) { + json_object_object_add(entry, "class", + json_object_new_string(re)); + free(re); + added = true; + } + } + if (instance) { + char *re = anchored_regex(instance); + if (re) { + json_object_object_add(entry, "instance", + json_object_new_string(re)); + free(re); + added = true; + } + } + + if (!added) { + json_object_put(entry); + return NULL; + } + + struct json_object *arr = json_object_new_array(); + json_object_array_add(arr, entry); + return arr; +} + +static void copy_key(struct json_object *src, struct json_object *dst, + const char *key) { + struct json_object *v; + if (json_object_object_get_ex(src, key, &v)) { + json_object_object_add(dst, key, json_object_get(v)); + } +} + +static struct json_object *build_layout_node(struct json_object *src) { + struct json_object *out = json_object_new_object(); + + copy_key(src, out, "layout"); + copy_key(src, out, "name"); + copy_key(src, out, "border"); + copy_key(src, out, "current_border_width"); + copy_key(src, out, "percent"); + copy_key(src, out, "marks"); + + // Unfilled placeholders carry their original swallows; pass it through. + struct json_object *swallows; + if (json_object_object_get_ex(src, "swallows", &swallows) && + json_object_is_type(swallows, json_type_array) && + json_object_array_length(swallows) > 0) { + json_object_object_add(out, "swallows", json_object_get(swallows)); + return out; + } + + struct json_object *nodes; + bool has_nodes = json_object_object_get_ex(src, "nodes", &nodes) && + json_object_is_type(nodes, json_type_array) && + json_object_array_length(nodes) > 0; + + if (has_nodes) { + struct json_object *out_nodes = json_object_new_array(); + size_t n = json_object_array_length(nodes); + for (size_t i = 0; i < n; i++) { + struct json_object *child = json_object_array_get_idx(nodes, i); + struct json_object *built = build_layout_node(child); + if (built) { + json_object_array_add(out_nodes, built); + } + } + if (json_object_array_length(out_nodes) == 0) { + json_object_put(out_nodes); + json_object_put(out); + return NULL; + } + json_object_object_add(out, "nodes", out_nodes); + return out; + } + + struct json_object *synth = synth_swallows(src); + if (!synth) { + json_object_put(out); + return NULL; + } + json_object_object_add(out, "swallows", synth); + return out; +} + +// Recursive search of the tree for the requested workspace. +static struct json_object *find_workspace(struct json_object *node, + const char *name) { + struct json_object *type; + if (json_object_object_get_ex(node, "type", &type) && + json_object_get_type(type) == json_type_string && + strcmp(json_object_get_string(type), "workspace") == 0) { + struct json_object *ws_name; + if (json_object_object_get_ex(node, "name", &ws_name) && + strcmp(json_object_get_string(ws_name), name) == 0) { + return node; + } + } + struct json_object *nodes; + if (json_object_object_get_ex(node, "nodes", &nodes) && + json_object_is_type(nodes, json_type_array)) { + size_t n = json_object_array_length(nodes); + for (size_t i = 0; i < n; i++) { + struct json_object *child = json_object_array_get_idx(nodes, i); + struct json_object *match = find_workspace(child, name); + if (match) { + return match; + } + } + } + return NULL; +} + +int main(int argc, char **argv) { + const char *workspace = NULL; + static const struct option long_opts[] = { + {"workspace", required_argument, NULL, 'w'}, + {"help", no_argument, NULL, 'h'}, + {0, 0, 0, 0 }, + }; + int opt; + while ((opt = getopt_long(argc, argv, "w:h", long_opts, NULL)) != -1) { + switch (opt) { + case 'w': + workspace = optarg; + break; + case 'h': + default: + usage(); + return opt == 'h' ? 0 : 1; + } + } + if (!workspace) { + usage(); + return 1; + } + + char *socket_path = get_socketpath(); + if (!socket_path) { + fprintf(stderr, "sway-save-tree: cannot find sway IPC socket\n"); + return 1; + } + int fd = ipc_open_socket(socket_path); + free(socket_path); + struct timeval timeout = {.tv_sec = 3, .tv_usec = 0}; + ipc_set_recv_timeout(fd, timeout); + uint32_t len = 0; + char *resp = ipc_single_command(fd, IPC_GET_TREE, "", &len); + if (!resp) { + fprintf(stderr, "sway-save-tree: GET_TREE IPC failed\n"); + return 1; + } + + struct json_tokener *tok = json_tokener_new(); + struct json_object *root = json_tokener_parse_ex(tok, resp, len); + json_tokener_free(tok); + free(resp); + if (!root) { + fprintf(stderr, "sway-save-tree: failed to parse GET_TREE response\n"); + return 1; + } + + struct json_object *ws = find_workspace(root, workspace); + if (!ws) { + fprintf(stderr, "sway-save-tree: workspace '%s' not found\n", + workspace); + json_object_put(root); + return 1; + } + + struct json_object *floating; + if (json_object_object_get_ex(ws, "floating_nodes", &floating) && + json_object_is_type(floating, json_type_array) && + json_object_array_length(floating) > 0) { + fprintf(stderr, "sway-save-tree: ignoring %zu floating window(s) on " + "workspace '%s' (tiling-only in this release)\n", + json_object_array_length(floating), workspace); + } + + struct json_object *nodes; + if (!json_object_object_get_ex(ws, "nodes", &nodes) || + !json_object_is_type(nodes, json_type_array) || + json_object_array_length(nodes) == 0) { + fprintf(stderr, "sway-save-tree: workspace '%s' has no tiling " + "children\n", workspace); + json_object_put(root); + return 1; + } + + struct json_object *out = json_object_new_array(); + size_t n = json_object_array_length(nodes); + for (size_t i = 0; i < n; i++) { + struct json_object *child = json_object_array_get_idx(nodes, i); + struct json_object *built = build_layout_node(child); + if (built) { + json_object_array_add(out, built); + } + } + + const char *str = json_object_to_json_string_ext(out, + JSON_C_TO_STRING_PRETTY); + puts(str); + + json_object_put(out); + json_object_put(root); + return 0; +} diff --git a/swaysavetree/meson.build b/swaysavetree/meson.build new file mode 100644 index 0000000000..0ec86f6e16 --- /dev/null +++ b/swaysavetree/meson.build @@ -0,0 +1,8 @@ +executable( + 'sway-save-tree', + 'main.c', + include_directories: [sway_inc], + dependencies: [jsonc], + link_with: [lib_sway_common], + install: true +) From b1ad1b3df353692856ef5e972d93252bc2c56e47 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 2 May 2026 11:05:00 -0600 Subject: [PATCH 07/13] docs: document append_layout and sway-save-tree Add sway(5) entry next to assign and a sway-save-tree(1) page; register the new manpage in meson.build. --- meson.build | 1 + sway/sway.5.scd | 40 ++++++++++++++++++++++++ swaysavetree/sway-save-tree.1.scd | 52 +++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 swaysavetree/sway-save-tree.1.scd diff --git a/meson.build b/meson.build index 98afc98fdf..f2b7f2c850 100644 --- a/meson.build +++ b/meson.build @@ -127,6 +127,7 @@ if scdoc.found() 'sway/sway-output.5.scd', 'swaybar/swaybar-protocol.7.scd', 'swaymsg/swaymsg.1.scd', + 'swaysavetree/sway-save-tree.1.scd', ] if get_option('swaynag') diff --git a/sway/sway.5.scd b/sway/sway.5.scd index 952d243d29..a56ec5c0f1 100644 --- a/sway/sway.5.scd +++ b/sway/sway.5.scd @@ -414,6 +414,46 @@ set|plus|minus|toggle The following commands may be used either in the configuration file or at runtime. +*append_layout* + Appends the container tree described by the JSON file at _path_ to the + currently focused workspace. Useful for restoring a saved layout: each + leaf container in the JSON is a _placeholder_ with a *swallows* array of + matchers; when a window opens whose properties match an unmatched + placeholder, sway installs that window into the placeholder's slot + instead of the usual focus-based placement. + + The format mirrors i3's, with one extension: in addition to i3's + *class*, *instance*, *title*, *window_role*, and *window_type* keys + (which target xwayland views), each *swallows* entry may include + *app_id* to match Wayland views. The *machine* key is logged and + ignored. All values are PCRE2 regex strings. + + A minimal example targeting one Wayland and one xwayland window in + a vertical split: + +``` +[ + { + "layout": "splitv", + "nodes": [ + { "swallows": [{ "app_id": "^foot$" }] }, + { "swallows": [{ "class": "^Firefox$" }] } + ] + } +] +``` + + The companion command-line tool *sway-save-tree*(1) generates such + files from a live workspace. The i3-save-tree(1) concatenated-object + output form is also accepted. + + Floating placeholders (the *floating_nodes* JSON array) are not + supported in this release; entries are skipped with a debug-log entry. + Placeholder match runs before *assign* rules: an explicit slot wins + over workspace assignment. *for_window* rules still run on the + swallowed view after placement. If multiple placeholders match the + same incoming view, the depth-first, document-order first match wins. + *assign* [→] [workspace] [number] Assigns windows matching _criteria_ (see *CRITERIA* for details) to _workspace_. The → (U+2192) is optional and cosmetic. This command is diff --git a/swaysavetree/sway-save-tree.1.scd b/swaysavetree/sway-save-tree.1.scd new file mode 100644 index 0000000000..7d00902ae2 --- /dev/null +++ b/swaysavetree/sway-save-tree.1.scd @@ -0,0 +1,52 @@ +sway-save-tree(1) + +# NAME + +sway-save-tree - Dump a workspace's tiling tree as append_layout-compatible JSON. + +# SYNOPSIS + +_sway-save-tree_ --workspace + +# DESCRIPTION + +Reads the live tree of the running sway instance over the IPC socket, walks +the named workspace, prunes runtime fields, and emits a JSON document +suitable for *swaymsg append_layout*(5). Each leaf view becomes a +placeholder entry whose *swallows* array contains a regex-anchored match on +the view's *app_id* (Wayland) or *class* / *instance* (xwayland). + +The output is sent to standard output. Pre-existing placeholders that were +loaded by *append_layout* and have not yet swallowed a window are echoed +verbatim from their *swallows* array. + +This is the sway counterpart of i3-save-tree(1). + +# OPTIONS + +*--workspace* + Name (or number, as a string) of the workspace to dump. Required. + +*-h, --help* + Show help and exit. + +# LIMITATIONS + +Floating windows are skipped in this release; only tiling children of the +workspace are emitted. A warning is printed to stderr when floating windows +are present. + +# EXAMPLES + +Save the layout of workspace 1 to a file, kill the running windows, and +restore the layout: + +``` +$ sway-save-tree --workspace 1 > /tmp/layout.json +$ swaymsg [workspace=1] kill +$ swaymsg append_layout /tmp/layout.json +``` + +# SEE ALSO + +*sway*(5), *sway*(1), *swaymsg*(1) From bc357224b8eec2dff0fca7887f3589745aecd8cb Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 2 May 2026 14:00:00 -0600 Subject: [PATCH 08/13] tree/load_layout: bypass default_layout when appending workspace_add_tiling wraps each top-level container with container_split when config->default_layout is set, mutating restored layouts. Use workspace_insert_tiling_direct so the parsed subtree is attached as-is. --- sway/tree/load_layout.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c index 8a9493823a..69ec8bfe01 100644 --- a/sway/tree/load_layout.c +++ b/sway/tree/load_layout.c @@ -446,8 +446,11 @@ bool load_layout_from_file(struct sway_workspace *ws, const char *path, return false; } + // workspace_add_tiling would wrap each child with container_split when + // default_layout is set, which mutates the parsed tree. for (int i = 0; i < children->length; i++) { - workspace_add_tiling(ws, children->items[i]); + workspace_insert_tiling_direct(ws, children->items[i], + ws->tiling->length); } list_free(children); From d882281cde18500e62b3fbd51274ab1846eddb13 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 2 May 2026 15:30:00 -0600 Subject: [PATCH 09/13] tree/load_layout: escape quotes in swallow values Swallow values were embedded directly into the synthesised criteria string; a value containing " produced a malformed [key="..."..."] that the criteria tokenizer could not parse, so layouts with quoted titles failed to load. Escape " as \" before embedding so the value survives the tokenizer and is recovered by the existing criteria unescape. --- sway/tree/load_layout.c | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c index 69ec8bfe01..e1addaecfb 100644 --- a/sway/tree/load_layout.c +++ b/sway/tree/load_layout.c @@ -140,21 +140,42 @@ static enum sway_container_border parse_border_name(const char *s) { return B_NORMAL; } -// Append a key="value" fragment to a malloc'd, null-terminated buffer. The -// value is the raw regex from the swallows entry; we trust json-c to give us -// a NUL-terminated string and we do NOT escape internal quotes, i3-save-tree -// already escapes them in its output and hand-written layouts must follow the -// same rule. +// Escape `"` so the value survives the criteria tokenizer. Backslashes are +// left alone since PCRE consumes them and criteria's unescape only collapses +// `\"`. +static char *escape_criteria_value(const char *value) { + size_t n = strlen(value); + char *out = malloc(n * 2 + 1); + if (!out) { + return NULL; + } + size_t pos = 0; + for (size_t i = 0; i < n; i++) { + if (value[i] == '"') { + out[pos++] = '\\'; + } + out[pos++] = value[i]; + } + out[pos] = '\0'; + return out; +} + static bool append_key_value(char **buf, const char *key, const char *value) { + char *escaped = escape_criteria_value(value); + if (!escaped) { + return false; + } size_t old = *buf ? strlen(*buf) : 0; - size_t add = strlen(key) + strlen(value) + 5; // ` k="v"` + size_t add = strlen(key) + strlen(escaped) + 5; // ` k="v"` char *grown = realloc(*buf, old + add + 1); if (!grown) { + free(escaped); return false; } *buf = grown; int written = snprintf(grown + old, add + 1, "%s%s=\"%s\"", - old ? " " : "", key, value); + old ? " " : "", key, escaped); + free(escaped); if (written < 0) { return false; } From 6382fe146926019677464b7b58b5634ace4bc61a Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 2 May 2026 16:10:00 -0600 Subject: [PATCH 10/13] tree/load_layout: give placeholder leaves L_HORIZ layout A placeholder leaf has view==NULL, so arrange_container routes it to its view-less branch, which calls arrange_children with the container's layout. arrange_children asserts on L_NONE, so a plain { swallows: [...] } JSON entry crashed assert-enabled builds before any window could match. Default the layout to L_HORIZ when JSON does not specify one. The placeholder has no children, so the iteration short-circuits without rendering anything until promoted. --- sway/tree/load_layout.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c index e1addaecfb..10f26d3c5b 100644 --- a/sway/tree/load_layout.c +++ b/sway/tree/load_layout.c @@ -386,10 +386,12 @@ static struct sway_container *build_node(struct json_object *obj, c->swallows = sw; // Retained so IPC can echo it verbatim for round-trip. c->swallows_json = json_object_get(swallows_v); + // arrange_children asserts on L_NONE; placeholders end up there + // via the view-less branch even with no children. + if (c->pending.layout == L_NONE) { + c->pending.layout = L_HORIZ; + } } else { - // Leaf without swallows is an empty split with no children. i3 does - // not produce these; treat as an error to avoid silently leaving - // orphaned containers. *error_out = format_str("append_layout: node has neither nodes nor " "swallows"); free_transient_subtree(c); From 54b95f619665317fc54b355349dd2880b7fa24b8 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 2 May 2026 17:25:00 -0600 Subject: [PATCH 11/13] swaysavetree: match workspace by number too --workspace was documented as accepting a name or a number, but the lookup only compared against the IPC name, so common workspaces such as "1: web" (num=1) could not be selected with --workspace 1. Parse a numeric argument and also compare against the num field. Update the manpage accordingly. --- swaysavetree/main.c | 24 ++++++++++++++++++++---- swaysavetree/sway-save-tree.1.scd | 4 +++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/swaysavetree/main.c b/swaysavetree/main.c index da64cce69a..3dc47e1dc9 100644 --- a/swaysavetree/main.c +++ b/swaysavetree/main.c @@ -2,6 +2,7 @@ // append_layout`. Counterpart to i3-save-tree(1). #include +#include #include #include #include @@ -168,9 +169,9 @@ static struct json_object *build_layout_node(struct json_object *src) { return out; } -// Recursive search of the tree for the requested workspace. +// Matches by name; if numeric >= 0, also matches num so "1" finds "1: web". static struct json_object *find_workspace(struct json_object *node, - const char *name) { + const char *name, int numeric) { struct json_object *type; if (json_object_object_get_ex(node, "type", &type) && json_object_get_type(type) == json_type_string && @@ -180,6 +181,13 @@ static struct json_object *find_workspace(struct json_object *node, strcmp(json_object_get_string(ws_name), name) == 0) { return node; } + if (numeric >= 0) { + struct json_object *num; + if (json_object_object_get_ex(node, "num", &num) && + json_object_get_int(num) == numeric) { + return node; + } + } } struct json_object *nodes; if (json_object_object_get_ex(node, "nodes", &nodes) && @@ -187,7 +195,7 @@ static struct json_object *find_workspace(struct json_object *node, size_t n = json_object_array_length(nodes); for (size_t i = 0; i < n; i++) { struct json_object *child = json_object_array_get_idx(nodes, i); - struct json_object *match = find_workspace(child, name); + struct json_object *match = find_workspace(child, name, numeric); if (match) { return match; } @@ -245,7 +253,15 @@ int main(int argc, char **argv) { return 1; } - struct json_object *ws = find_workspace(root, workspace); + int numeric = -1; + if (workspace[0] != '\0') { + char *end = NULL; + long n = strtol(workspace, &end, 10); + if (end != workspace && *end == '\0' && n >= 0 && n <= INT_MAX) { + numeric = (int)n; + } + } + struct json_object *ws = find_workspace(root, workspace, numeric); if (!ws) { fprintf(stderr, "sway-save-tree: workspace '%s' not found\n", workspace); diff --git a/swaysavetree/sway-save-tree.1.scd b/swaysavetree/sway-save-tree.1.scd index 7d00902ae2..90d5aefeda 100644 --- a/swaysavetree/sway-save-tree.1.scd +++ b/swaysavetree/sway-save-tree.1.scd @@ -25,7 +25,9 @@ This is the sway counterpart of i3-save-tree(1). # OPTIONS *--workspace* - Name (or number, as a string) of the workspace to dump. Required. + Name or number of the workspace to dump. Required. If the argument is + all digits, it is also matched against the workspace's number, so + *--workspace 1* finds a workspace named "1: web". *-h, --help* Show help and exit. From 0bb4aba044e78d421d318c0bc8568cae68fa594e Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 2 May 2026 17:56:46 -0600 Subject: [PATCH 12/13] tree/load_layout: fix two i3-save-tree compatibility gaps Strip C-style header comments before json parsing so the i3-save-tree vim modeline does not break }\n{ concat detection. Pass window_type to the criteria parser bare and unanchored, since the parser treats it as an enum token and the i3 form "^name$" was being silently dropped to ATOM_LAST. --- sway/tree/load_layout.c | 100 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c index 10f26d3c5b..31f3800841 100644 --- a/sway/tree/load_layout.c +++ b/sway/tree/load_layout.c @@ -55,6 +55,38 @@ static char *slurp_file(const char *path, char **error_out) { return buf; } +// i3-save-tree prepends a vim modeline; the leading `/` would otherwise +// confuse preprocess_i3_concat's array-vs-object check. +static char *strip_header_comments(char *buf) { + char *p = buf; + for (;;) { + while (*p && isspace((unsigned char)*p)) { + p++; + } + if (p[0] == '/' && p[1] == '/') { + while (*p && *p != '\n') { + p++; + } + continue; + } + if (p[0] == '/' && p[1] == '*') { + p += 2; + while (*p && !(p[0] == '*' && p[1] == '/')) { + p++; + } + if (*p) { + p += 2; + } + continue; + } + break; + } + if (p != buf) { + memmove(buf, p, strlen(p) + 1); + } + return buf; +} + // i3-save-tree emits a sequence of top-level objects separated by `}\n{` // rather than wrapping them in an array. Wrap into a strict JSON array. // Same string-literal caveat as i3's own loader. @@ -182,6 +214,42 @@ static bool append_key_value(char **buf, const char *key, const char *value) { return true; } +static bool append_bare(char **buf, const char *key, const char *value) { + size_t old = *buf ? strlen(*buf) : 0; + size_t add = strlen(key) + strlen(value) + 3; // ` k=v` + char *grown = realloc(*buf, old + add + 1); + if (!grown) { + return false; + } + *buf = grown; + int written = snprintf(grown + old, add + 1, "%s%s=%s", + old ? " " : "", key, value); + return written >= 0; +} + +// i3-save-tree emits window_type as a regex-anchored enum name like +// "^normal$". The criteria parser treats window_type as a bare enum token, +// not a regex, so the anchored form fails. Strip a single leading ^ and a +// single trailing $ before passing through. +static char *unanchor_enum(const char *value) { + size_t n = strlen(value); + size_t start = 0; + size_t end = n; + if (n > 0 && value[0] == '^') { + start = 1; + } + if (end > start && value[end - 1] == '$') { + end--; + } + char *out = malloc(end - start + 1); + if (!out) { + return NULL; + } + memcpy(out, value + start, end - start); + out[end - start] = '\0'; + return out; +} + // app_id is a sway extension over i3's swallows schema; machine is ignored. static struct criteria *build_swallow_criteria(struct json_object *entry, char **error_out) { @@ -189,27 +257,44 @@ static struct criteria *build_swallow_criteria(struct json_object *entry, *error_out = format_str("append_layout: swallows entry is not an object"); return NULL; } - static const char *keys[] = { - "class", "instance", "title", "window_role", "window_type", "app_id", - NULL, + static const char *regex_keys[] = { + "class", "instance", "title", "window_role", "app_id", NULL, }; char *body = NULL; - for (int i = 0; keys[i]; i++) { + for (int i = 0; regex_keys[i]; i++) { struct json_object *v; - if (!json_object_object_get_ex(entry, keys[i], &v)) { + if (!json_object_object_get_ex(entry, regex_keys[i], &v)) { continue; } if (!json_object_is_type(v, json_type_string)) { free(body); *error_out = format_str("append_layout: swallows.%s is not a string", - keys[i]); + regex_keys[i]); + return NULL; + } + if (!append_key_value(&body, regex_keys[i], + json_object_get_string(v))) { + free(body); + *error_out = format_str("append_layout: out of memory"); + return NULL; + } + } + struct json_object *wt; + if (json_object_object_get_ex(entry, "window_type", &wt)) { + if (!json_object_is_type(wt, json_type_string)) { + free(body); + *error_out = format_str( + "append_layout: swallows.window_type is not a string"); return NULL; } - if (!append_key_value(&body, keys[i], json_object_get_string(v))) { + char *bare = unanchor_enum(json_object_get_string(wt)); + if (!bare || !append_bare(&body, "window_type", bare)) { + free(bare); free(body); *error_out = format_str("append_layout: out of memory"); return NULL; } + free(bare); } struct json_object *machine; if (json_object_object_get_ex(entry, "machine", &machine)) { @@ -412,6 +497,7 @@ bool load_layout_from_file(struct sway_workspace *ws, const char *path, if (!buf) { return false; } + buf = strip_header_comments(buf); buf = preprocess_i3_concat(buf); struct json_tokener *tok = json_tokener_new(); From 93931c37a6d5a0a3b245fa4e5dea1a95f88d3591 Mon Sep 17 00:00:00 2001 From: codegax <14095200+codegax@users.noreply.github.com> Date: Sat, 2 May 2026 18:03:30 -0600 Subject: [PATCH 13/13] tree/load_layout: validate window_type literals strictly Anchored ^name$ and bare names are accepted; everything else, including regex alternation, returns a clear error instead of silently falling through to ATOM_LAST. --- sway/sway.5.scd | 8 +++++- sway/tree/load_layout.c | 56 ++++++++++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/sway/sway.5.scd b/sway/sway.5.scd index a56ec5c0f1..ddb535e78f 100644 --- a/sway/sway.5.scd +++ b/sway/sway.5.scd @@ -426,7 +426,13 @@ runtime. *class*, *instance*, *title*, *window_role*, and *window_type* keys (which target xwayland views), each *swallows* entry may include *app_id* to match Wayland views. The *machine* key is logged and - ignored. All values are PCRE2 regex strings. + ignored. *class*, *instance*, *title*, *window_role* and *app_id* + are PCRE2 regex strings. *window_type* must be a literal atom name + (*normal*, *dialog*, *utility*, *toolbar*, *splash*, *menu*, + *dropdown_menu*, *popup_menu*, *tooltip*, *notification*); the + i3-save-tree anchored form _^name$_ is accepted, but regex + alternation such as _^(normal|dialog)$_ is rejected, split such + patterns into multiple swallows entries (which match as OR). A minimal example targeting one Wayland and one xwayland window in a vertical split: diff --git a/sway/tree/load_layout.c b/sway/tree/load_layout.c index 31f3800841..790fa948d1 100644 --- a/sway/tree/load_layout.c +++ b/sway/tree/load_layout.c @@ -227,27 +227,38 @@ static bool append_bare(char **buf, const char *key, const char *value) { return written >= 0; } -// i3-save-tree emits window_type as a regex-anchored enum name like -// "^normal$". The criteria parser treats window_type as a bare enum token, -// not a regex, so the anchored form fails. Strip a single leading ^ and a -// single trailing $ before passing through. -static char *unanchor_enum(const char *value) { +// window_type is parsed as an enum, not a regex. Accept i3-save-tree's +// "^name$" form and bare names; reject anything else (including alternation) +// rather than letting the criteria parser silently fall through to ATOM_LAST. +static const char *known_window_types[] = { + "normal", "dialog", "utility", "toolbar", "splash", "menu", + "dropdown_menu", "popup_menu", "tooltip", "notification", NULL, +}; + +static char *parse_window_type_value(const char *value, char **error_out) { size_t n = strlen(value); - size_t start = 0; - size_t end = n; - if (n > 0 && value[0] == '^') { - start = 1; - } - if (end > start && value[end - 1] == '$') { - end--; - } - char *out = malloc(end - start + 1); - if (!out) { + size_t start = (n > 0 && value[0] == '^') ? 1 : 0; + size_t end = (n > start && value[n - 1] == '$') ? n - 1 : n; + size_t bare_len = end - start; + char *bare = malloc(bare_len + 1); + if (!bare) { + *error_out = format_str("append_layout: out of memory"); return NULL; } - memcpy(out, value + start, end - start); - out[end - start] = '\0'; - return out; + memcpy(bare, value + start, bare_len); + bare[bare_len] = '\0'; + for (int i = 0; known_window_types[i]; i++) { + if (strcasecmp(bare, known_window_types[i]) == 0) { + return bare; + } + } + *error_out = format_str("append_layout: window_type %s is not a " + "supported literal value (use one of: normal, dialog, " + "utility, toolbar, splash, menu, dropdown_menu, popup_menu, " + "tooltip, notification; regex alternation is not supported, " + "split into multiple swallow entries instead)", value); + free(bare); + return NULL; } // app_id is a sway extension over i3's swallows schema; machine is ignored. @@ -287,8 +298,13 @@ static struct criteria *build_swallow_criteria(struct json_object *entry, "append_layout: swallows.window_type is not a string"); return NULL; } - char *bare = unanchor_enum(json_object_get_string(wt)); - if (!bare || !append_bare(&body, "window_type", bare)) { + char *bare = parse_window_type_value(json_object_get_string(wt), + error_out); + if (!bare) { + free(body); + return NULL; + } + if (!append_bare(&body, "window_type", bare)) { free(bare); free(body); *error_out = format_str("append_layout: out of memory");