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.
This commit is contained in:
codegax 2026-04-28 19:42:00 -06:00
parent 83d6fef24e
commit 21358c7c0e
3 changed files with 499 additions and 0 deletions

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

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

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

@ -0,0 +1,464 @@
#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/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;
}