This commit is contained in:
Axel 2026-06-06 07:02:01 +00:00 committed by GitHub
commit e7da535183
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1324 additions and 54 deletions

View file

@ -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;

View file

@ -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.
*/

View file

@ -9,6 +9,7 @@
struct sway_view;
struct sway_seat;
struct json_object;
enum sway_container_layout {
L_NONE,
@ -141,11 +142,20 @@ 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 json_object *swallows_json;
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);

View file

@ -0,0 +1,34 @@
#ifndef _SWAY_LOAD_LAYOUT_H
#define _SWAY_LOAD_LAYOUT_H
#include <stdbool.h>
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

View file

@ -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')
@ -195,6 +196,7 @@ subdir('protocols')
subdir('common')
subdir('sway')
subdir('swaymsg')
subdir('swaysavetree')
if get_option('swaybar') or get_option('swaynag')
subdir('client')

View file

@ -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 },

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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',
@ -211,6 +212,7 @@ sway_sources = files(
'tree/arrange.c',
'tree/container.c',
'tree/load_layout.c',
'tree/node.c',
'tree/root.c',
'tree/view.c',

View file

@ -414,6 +414,52 @@ set|plus|minus|toggle <amount>
The following commands may be used either in the configuration file or at
runtime.
*append_layout* <path>
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. *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:
```
[
{
"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* <criteria> [→] [workspace] [number] <workspace>
Assigns windows matching _criteria_ (see *CRITERIA* for details) to
_workspace_. The → (U+2192) is optional and cosmetic. This command is

View file

@ -7,7 +7,9 @@
#include <wlr/types/wlr_linux_dmabuf_v1.h>
#include <wlr/types/wlr_output_layout.h>
#include <wlr/types/wlr_subcompositor.h>
#include <json.h>
#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 +45,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 +96,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 +466,16 @@ 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->swallows_json) {
json_object_put(con->swallows_json);
}
if (con->view && con->view->container == con) {
con->view->container = NULL;
if (con->view->destroying) {
@ -509,11 +525,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;

636
sway/tree/load_layout.c Normal file
View file

@ -0,0 +1,636 @@
#include <ctype.h>
#include <errno.h>
#include <json.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include "list.h"
#include "log.h"
#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"
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 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.
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;
}
// 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(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, escaped);
free(escaped);
if (written < 0) {
return false;
}
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;
}
// 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 = (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(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.
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 *regex_keys[] = {
"class", "instance", "title", "window_role", "app_id", NULL,
};
char *body = NULL;
for (int i = 0; regex_keys[i]; i++) {
struct json_object *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",
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;
}
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");
return NULL;
}
free(bare);
}
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;
// 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 {
*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 = strip_header_comments(buf);
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;
}
// 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_insert_tiling_direct(ws, children->items[i],
ws->tiling->length);
}
list_free(children);
arrange_workspace(ws);
transaction_commit_dirty();
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) {
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;
}

View file

@ -16,6 +16,7 @@
#if WLR_HAS_XWAYLAND
#include <wlr/xwayland.h>
#endif
#include <json.h>
#include "list.h"
#include "log.h"
#include "sway/criteria.h"
@ -32,6 +33,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 +811,45 @@ 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;
}
if (placeholder->swallows_json) {
json_object_put(placeholder->swallows_json);
placeholder->swallows_json = 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 +858,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 +877,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 +900,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 +951,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 +964,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);

308
swaysavetree/main.c Normal file
View file

@ -0,0 +1,308 @@
// sway-save-tree: dump a workspace's tiling tree as JSON for `swaymsg
// append_layout`. Counterpart to i3-save-tree(1).
#include <getopt.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/time.h>
#include <json.h>
#include "ipc-client.h"
#include "ipc.h"
#include "log.h"
static void usage(void) {
fprintf(stderr,
"Usage: sway-save-tree --workspace <name|number>\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;
}
// 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, int numeric) {
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;
}
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) &&
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, numeric);
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;
}
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);
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;
}

8
swaysavetree/meson.build Normal file
View file

@ -0,0 +1,8 @@
executable(
'sway-save-tree',
'main.c',
include_directories: [sway_inc],
dependencies: [jsonc],
link_with: [lib_sway_common],
install: true
)

View file

@ -0,0 +1,54 @@
sway-save-tree(1)
# NAME
sway-save-tree - Dump a workspace's tiling tree as append_layout-compatible JSON.
# SYNOPSIS
_sway-save-tree_ --workspace <name>
# 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>
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.
# 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)