Merge branch 'kanshi-config'

This commit is contained in:
Mars-Wave
2026-09-18 18:53:34 +02:00
11 changed files with 948 additions and 24 deletions
+11 -2
View File
@@ -9,6 +9,7 @@ Compositors that are known to support the protocol are [Sway] and [Wayfire].
The goal of this project is to allow precise adjustment of display settings in
kiosks, digital signage, and other elaborate multi-monitor setups.
![Screenshot](wdisplays.png)
# Installation
@@ -70,8 +71,16 @@ It's intended to be the Wayland equivalent of an xrandr GUI, like [ARandR].
Sway, like i3, doesn't save any settings unless you put them in the config
file. See man `sway-output`. If you want to have multiple configurations
depending on the monitors connected, you'll need to use an external program
like [kanshi] or [way-displays]. Integration with that and other external
daemons is planned.
like [kanshi] or [way-displays].
When you apply a new change, the setting will be defaultly added to $HOME/.config/kanshi/config,
if there is already a profile for the same monitors combination, the change will be applied on
existing one.
you can add kanshi autostart to your sway config:
```
exec kanshi
exec_always kanshictl reload
```
### How do I add support to my compositor?
+1
View File
@@ -10,6 +10,7 @@ conf = configuration_data({
'app_id': meson.project_name(),
'version': meson.project_version(),
'resource_prefix': '/' / '/'.join(meson.project_name().split('.')),
'WITH_KANSHI': get_option('kanshi').allowed(),
})
subdir('protocol')
+3
View File
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: CC0-1.0
# SPDX-FileCopyrightText: NONE
option('kanshi', type:'feature', description: 'Build support for saving config for the kanshi daemon')
+77 -6
View File
@@ -39,7 +39,7 @@
interface version number is reset.
</description>
<interface name="zwlr_output_manager_v1" version="1">
<interface name="zwlr_output_manager_v1" version="2">
<description summary="output device configuration manager">
This interface is a manager that allows reading and writing the current
output device configuration.
@@ -115,7 +115,7 @@
</description>
</request>
<event name="finished">
<event name="finished" type="destructor">
<description summary="the compositor has finished with the manager">
This event indicates that the compositor is done sending manager events.
The compositor will destroy the object immediately after sending this
@@ -125,7 +125,7 @@
</event>
</interface>
<interface name="zwlr_output_head_v1" version="1">
<interface name="zwlr_output_head_v1" version="2">
<description summary="output device">
A head is an output device. The difference between a wl_output object and
a head is that heads are advertised even if they are turned off. A head
@@ -257,9 +257,80 @@
resources associated with it.
</description>
</event>
<!-- Version 2 additions -->
<event name="make" since="2">
<description summary="head manufacturer">
This event describes the manufacturer of the head.
This must report the same make as the wl_output interface does in its
geometry event.
Together with the model and serial_number events the purpose is to
allow clients to recognize heads from previous sessions and for example
load head-specific configurations back.
It is not guaranteed this event will be ever sent. A reason for that
can be that the compositor does not have information about the make of
the head or the definition of a make is not sensible in the current
setup, for example in a virtual session. Clients can still try to
identify the head by available information from other events but should
be aware that there is an increased risk of false positives.
It is not recommended to display the make string in UI to users. For
that the string provided by the description event should be preferred.
</description>
<arg name="make" type="string"/>
</event>
<event name="model" since="2">
<description summary="head model">
This event describes the model of the head.
This must report the same model as the wl_output interface does in its
geometry event.
Together with the make and serial_number events the purpose is to
allow clients to recognize heads from previous sessions and for example
load head-specific configurations back.
It is not guaranteed this event will be ever sent. A reason for that
can be that the compositor does not have information about the model of
the head or the definition of a model is not sensible in the current
setup, for example in a virtual session. Clients can still try to
identify the head by available information from other events but should
be aware that there is an increased risk of false positives.
It is not recommended to display the model string in UI to users. For
that the string provided by the description event should be preferred.
</description>
<arg name="model" type="string"/>
</event>
<event name="serial_number" since="2">
<description summary="head serial number">
This event describes the serial number of the head.
Together with the make and model events the purpose is to allow clients
to recognize heads from previous sessions and for example load head-
specific configurations back.
It is not guaranteed this event will be ever sent. A reason for that
can be that the compositor does not have information about the serial
number of the head or the definition of a serial number is not sensible
in the current setup. Clients can still try to identify the head by
available information from other events but should be aware that there
is an increased risk of false positives.
It is not recommended to display the serial_number string in UI to
users. For that the string provided by the description event should be
preferred.
</description>
<arg name="serial_number" type="string"/>
</event>
</interface>
<interface name="zwlr_output_mode_v1" version="1">
<interface name="zwlr_output_mode_v1" version="2">
<description summary="output mode">
This object describes an output mode.
@@ -305,7 +376,7 @@
</event>
</interface>
<interface name="zwlr_output_configuration_v1" version="1">
<interface name="zwlr_output_configuration_v1" version="2">
<description summary="output configuration">
This object is used by the client to describe a full output configuration.
@@ -423,7 +494,7 @@
</request>
</interface>
<interface name="zwlr_output_configuration_head_v1" version="1">
<interface name="zwlr_output_configuration_head_v1" version="2">
<description summary="head configuration">
This object is used by the client to update a single head's configuration.
+3
View File
@@ -10,5 +10,8 @@
<key name="show-overlay" type="b">
<default>true</default>
</key>
<key name="save-kanshi-config" type="b">
<default>false</default>
</key>
</schema>
</schemalist>
+3
View File
@@ -8,4 +8,7 @@
#define WDISPLAYS_VERSION "@version@"
#define WDISPLAYS_RESOURCE_PREFIX "@resource_prefix@"
// Features
#mesondefine WITH_KANSHI
#endif
+42 -3
View File
@@ -55,10 +55,12 @@ static gboolean send_apply(gpointer data) {
struct wl_display *wl_display = gdk_wayland_display_get_wl_display(display);
wd_apply_state(state, outputs, wl_display);
state->apply_pending = FALSE;
state->apply_manual = false;
return FALSE;
}
static void apply_state(struct wd_state *state) {
static void apply_state(struct wd_state *state, bool manual) {
state->apply_manual = state->apply_manual || manual;
gtk_stack_set_visible_child_name(GTK_STACK(state->header_stack), "title");
if (!state->autoapply) {
gtk_style_context_add_class(gtk_widget_get_style_context(state->spinner), "visible");
@@ -365,7 +367,7 @@ static void show_apply(struct wd_state *state) {
const gchar *page = "title";
if (has_changes(state)) {
if (state->autoapply) {
apply_state(state);
apply_state(state, false);
} else {
page = "apply";
}
@@ -469,6 +471,10 @@ void wd_ui_apply_done(struct wd_state *state, struct wl_list *outputs) {
}
void wd_ui_show_error(struct wd_state *state, const char *message) {
if (gtk_widget_in_destruction(gtk_widget_get_toplevel(state->info_bar))) {
fprintf(stderr, "%s\n", message);
return;
}
gtk_label_set_text(GTK_LABEL(state->info_label), message);
gtk_widget_show(state->info_bar);
gtk_info_bar_set_revealed(GTK_INFO_BAR(state->info_bar), TRUE);
@@ -487,6 +493,9 @@ static void cleanup(GtkWidget *window, gpointer data) {
g_object_unref(state->grabbing_cursor);
g_object_unref(state->move_cursor);
g_clear_object(&state->settings);
#ifdef WITH_KANSHI
wd_save_kanshi_snapshot(state);
#endif
wd_state_destroy(state);
}
@@ -899,7 +908,7 @@ static void cancel_changes(GSimpleAction *action, GVariant *param, gpointer data
}
static void apply_changes(GSimpleAction *action, GVariant *param, gpointer data) {
apply_state(data);
apply_state(data, true);
}
static void info_response(GtkInfoBar *info_bar, gint response_id, gpointer data) {
@@ -921,6 +930,11 @@ static void auto_apply_selected(GSimpleAction *action, GVariant *param, gpointer
}
state->autoapply = g_variant_get_boolean(param);
g_simple_action_set_state(action, param);
#ifdef WITH_KANSHI
if (!state->autoapply) {
wd_save_kanshi_snapshot(state);
}
#endif
}
static gboolean redraw_canvas(GtkWidget *widget, GdkFrameClock *frame_clock, gpointer data) {
@@ -958,6 +972,18 @@ static void overlay_selected(GSimpleAction *action, GVariant *param, gpointer da
}
}
#ifdef WITH_KANSHI
static void save_kanshi_config_selected(GSimpleAction *action, GVariant *param, gpointer data) {
struct wd_state *state = data;
if (state->settings != NULL) {
g_settings_set_boolean(state->settings, "save-kanshi-config", g_variant_get_boolean(param));
}
state->save_kanshi_config = g_variant_get_boolean(param);
wd_drop_kanshi_snapshot(state);
g_simple_action_set_state(action, param);
}
#endif
static void window_state_changed(GtkWidget *window, GdkEventWindowState *event,
gpointer data) {
struct wd_state *state = data;
@@ -1150,6 +1176,9 @@ static void activate(GtkApplication* app, gpointer user_data) {
state->autoapply = g_settings_get_boolean(state->settings, "auto-apply");
state->capture = g_settings_get_boolean(state->settings, "capture-screens");
state->show_overlay = g_settings_get_boolean(state->settings, "show-overlay");
#ifdef WITH_KANSHI
state->save_kanshi_config = g_settings_get_boolean(state->settings, "save-kanshi-config");
#endif
}
if (startup->autoapply != -1) {
state->autoapply = startup->autoapply;
@@ -1177,10 +1206,20 @@ static void activate(GtkApplication* app, gpointer user_data) {
g_signal_connect(overlay_action, "change-state", G_CALLBACK(overlay_selected), state);
g_action_map_add_action(G_ACTION_MAP(main_actions), G_ACTION(overlay_action));
#ifdef WITH_KANSHI
action = g_simple_action_new_stateful("save-kanshi-config", NULL,
g_variant_new_boolean(state->save_kanshi_config));
g_signal_connect(action, "change-state", G_CALLBACK(save_kanshi_config_selected), state);
g_action_map_add_action(G_ACTION_MAP(main_actions), G_ACTION(action));
#endif
GMenu *main_menu = g_menu_new();
g_menu_append(main_menu, "_Automatically Apply Changes", "app.auto-apply");
g_menu_append(main_menu, "_Show Screen Contents", "app.capture-screens");
g_menu_append(main_menu, "_Overlay Screen Names", "app.show-overlay");
#ifdef WITH_KANSHI
g_menu_append(main_menu, "Save to _kanshi Config", "app.save-kanshi-config");
#endif
gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(state->menu_button), G_MENU_MODEL(main_menu));
gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(state->menu_button), false);
+15 -9
View File
@@ -11,17 +11,23 @@ epoxy = dependency('epoxy')
configure_file(input: 'config.h.in', output: 'config.h', configuration: conf)
sources = [
'main.c',
'glviewport.c',
'headform.c',
'outputs.c',
'overlay.c',
'render.c',
resources,
]
if get_option('kanshi').allowed()
sources += 'store.c'
endif
executable(
'wdisplays',
[
'main.c',
'glviewport.c',
'headform.c',
'outputs.c',
'overlay.c',
'render.c',
resources,
],
sources,
dependencies : [
m_dep,
rt_dep,
+92 -4
View File
@@ -35,23 +35,60 @@ static void noop() {
struct wd_pending_config {
struct wd_state *state;
struct wl_list *outputs;
struct wl_list link;
bool manual;
bool incomplete;
};
static void destroy_pending(struct wd_pending_config *pending) {
static void destroy_outputs(struct wl_list *outputs) {
struct wd_head_config *output, *tmp;
wl_list_for_each_safe(output, tmp, pending->outputs, link) {
wl_list_for_each_safe(output, tmp, outputs, link) {
wl_list_remove(&output->link);
free(output);
}
free(pending->outputs);
free(outputs);
}
static void destroy_pending(struct wd_pending_config *pending) {
wl_list_remove(&pending->link);
if (pending->outputs != NULL) {
destroy_outputs(pending->outputs);
}
free(pending);
}
#ifdef WITH_KANSHI
void wd_drop_kanshi_snapshot(struct wd_state *state) {
if (state->kanshi_snapshot != NULL) {
destroy_outputs(state->kanshi_snapshot);
state->kanshi_snapshot = NULL;
}
}
void wd_save_kanshi_snapshot(struct wd_state *state) {
if (state->kanshi_snapshot != NULL) {
wd_store_config(state, state->kanshi_snapshot);
wd_drop_kanshi_snapshot(state);
}
}
#endif
static void config_handle_succeeded(void *data,
struct zwlr_output_configuration_v1 *config) {
struct wd_pending_config *pending = data;
zwlr_output_configuration_v1_destroy(config);
wd_ui_apply_done(pending->state, pending->outputs);
#ifdef WITH_KANSHI
if (pending->state->save_kanshi_config && !pending->incomplete) {
wd_drop_kanshi_snapshot(pending->state);
if (pending->manual) {
wd_store_config(pending->state, pending->outputs);
} else {
pending->state->kanshi_snapshot = pending->outputs;
pending->outputs = NULL;
}
}
#endif
destroy_pending(pending);
}
@@ -90,6 +127,8 @@ void wd_apply_state(struct wd_state *state, struct wl_list *new_outputs,
struct wd_pending_config *pending = calloc(1, sizeof(*pending));
pending->state = state;
pending->outputs = new_outputs;
pending->manual = state->apply_manual;
wl_list_insert(&state->pending_configs, &pending->link);
zwlr_output_configuration_v1_add_listener(config, &config_listener, pending);
@@ -395,6 +434,9 @@ static void wd_head_destroy(struct wd_head *head) {
zwlr_output_head_v1_destroy(head->wlr_head);
free(head->name);
free(head->description);
free(head->make);
free(head->model);
free(head->serial_number);
free(head);
}
@@ -420,6 +462,9 @@ static void mode_handle_preferred(void *data,
static void mode_handle_finished(void *data,
struct zwlr_output_mode_v1 *wlr_mode) {
struct wd_mode *mode = data;
#ifdef WITH_KANSHI
wd_save_kanshi_snapshot(mode->head->state);
#endif
if (mode->head->mode == mode) {
mode->head->mode = NULL;
}
@@ -517,10 +562,46 @@ static void head_handle_scale(void *data,
wd_ui_reset_head(head, WD_FIELD_SCALE);
}
static void head_handle_make(void *data,
struct zwlr_output_head_v1 *wlr_head, const char *make) {
struct wd_head *head = data;
head->make = strdup(make);
}
static void head_handle_model(void *data,
struct zwlr_output_head_v1 *wlr_head, const char *model) {
struct wd_head *head = data;
head->model = strdup(model);
}
static void head_handle_serial_number(void *data,
struct zwlr_output_head_v1 *wlr_head, const char *serial_number) {
struct wd_head *head = data;
head->serial_number = strdup(serial_number);
}
static void remove_pending_head(struct wd_state *state, struct wd_head *head) {
struct wd_pending_config *pending;
wl_list_for_each(pending, &state->pending_configs, link) {
struct wd_head_config *output, *tmp;
wl_list_for_each_safe(output, tmp, pending->outputs, link) {
if (output->head == head) {
wl_list_remove(&output->link);
free(output);
pending->incomplete = true;
}
}
}
}
static void head_handle_finished(void *data,
struct zwlr_output_head_v1 *wlr_head) {
struct wd_head *head = data;
struct wd_state *state = head->state;
#ifdef WITH_KANSHI
wd_save_kanshi_snapshot(state);
#endif
remove_pending_head(state, head);
wl_list_remove(&head->link);
wd_head_destroy(head);
@@ -548,12 +629,18 @@ static const struct zwlr_output_head_v1_listener head_listener = {
.transform = head_handle_transform,
.scale = head_handle_scale,
.finished = head_handle_finished,
.make = head_handle_make,
.model = head_handle_model,
.serial_number = head_handle_serial_number,
};
static void output_manager_handle_head(void *data,
struct zwlr_output_manager_v1 *manager,
struct zwlr_output_head_v1 *wlr_head) {
struct wd_state *state = data;
#ifdef WITH_KANSHI
wd_save_kanshi_snapshot(state);
#endif
struct wd_head *head = calloc(1, sizeof(*head));
head->state = state;
@@ -596,7 +683,7 @@ static void registry_handle_global(void *data, struct wl_registry *registry,
if (strcmp(interface, zwlr_output_manager_v1_interface.name) == 0) {
state->output_manager = wl_registry_bind(registry, name,
&zwlr_output_manager_v1_interface, 1);
&zwlr_output_manager_v1_interface, MIN(version, 2));
zwlr_output_manager_v1_add_listener(state->output_manager,
&output_manager_listener, state);
} else if (strcmp(interface, zxdg_output_manager_v1_interface.name) == 0) {
@@ -737,6 +824,7 @@ struct wd_state *wd_state_create(void) {
state->show_overlay = true;
wl_list_init(&state->heads);
wl_list_init(&state->outputs);
wl_list_init(&state->pending_configs);
wl_list_init(&state->render.heads);
return state;
}
+673
View File
@@ -0,0 +1,673 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2024-2025 Shaochang Tan
// SPDX-FileCopyrightText: 2024-2025 Jason André Charles Gantner
#include <errno.h>
#include <fnmatch.h>
#include <string.h>
#include <wordexp.h>
#include "wdisplays.h"
#define INCLUDE_DEPTH_MAX 16
struct kanshi_file {
char *path;
char *text;
GPtrArray *dirs;
};
struct kanshi_directive {
char *name;
GPtrArray *params;
GPtrArray *children;
size_t start, name_start, end;
size_t criteria_start, criteria_end;
struct kanshi_file *file;
};
struct kanshi_config {
GPtrArray *files;
GPtrArray *profiles;
GPtrArray *outputs;
};
struct kanshi_parser {
const char *text;
size_t pos;
int line;
};
static char *get_config_path(void) {
const char *env_path = g_getenv("WDISPLAYS_KANSHI_CONFIG");
if (env_path != NULL && env_path[0] != '\0') {
return g_strdup(env_path);
}
return g_build_filename(g_get_user_config_dir(), "kanshi", "config", NULL);
}
static void kanshi_directive_free(gpointer data) {
struct kanshi_directive *dir = data;
g_free(dir->name);
g_ptr_array_unref(dir->params);
if (dir->children != NULL) {
g_ptr_array_unref(dir->children);
}
g_free(dir);
}
static char peek(struct kanshi_parser *parser) {
return parser->text[parser->pos];
}
static void skip_blanks(struct kanshi_parser *parser) {
while (peek(parser) == ' ' || peek(parser) == '\t') {
parser->pos++;
}
}
static void next_line(struct kanshi_parser *parser) {
while (peek(parser) != '\0' && peek(parser) != '\n') {
parser->pos++;
}
if (peek(parser) == '\n') {
parser->pos++;
parser->line++;
}
}
static bool parse_error(struct kanshi_parser *parser, GError **error,
const char *message) {
g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"line %d: %s", parser->line, message);
return false;
}
static bool parse_word(struct kanshi_parser *parser, GString *word,
GError **error) {
char quote = peek(parser);
if (quote == '"' || quote == '\'') {
parser->pos++;
} else {
quote = '\0';
}
while (true) {
char c = peek(parser);
if (quote != '\0' && c == quote) {
parser->pos++;
return true;
}
if (c == '\0' || c == '\n') {
if (quote != '\0') {
return parse_error(parser, error, "unterminated quoted string");
}
return true;
}
if (quote == '\0' && (c == ' ' || c == '\t')) {
return true;
}
if (quote == '\0' && strchr("\"'{}", c) != NULL) {
return parse_error(parser, error, "unexpected character in word");
}
if (c == '\\' && quote != '\'') {
parser->pos++;
c = peek(parser);
if (c == '\0' || c == '\n') {
return parse_error(parser, error, "cannot escape a line break");
}
}
g_string_append_c(word, c);
parser->pos++;
}
}
static GPtrArray *parse_block(struct kanshi_parser *parser, bool nested,
GError **error);
static struct kanshi_directive *parse_directive(struct kanshi_parser *parser,
GError **error) {
struct kanshi_directive *dir = g_new0(struct kanshi_directive, 1);
dir->params = g_ptr_array_new_with_free_func(g_free);
dir->name_start = parser->pos;
dir->start = parser->pos;
while (dir->start > 0 && (parser->text[dir->start - 1] == ' '
|| parser->text[dir->start - 1] == '\t')) {
dir->start--;
}
if (dir->start > 0 && parser->text[dir->start - 1] != '\n') {
dir->start = dir->name_start;
}
g_autoptr(GString) word = g_string_new(NULL);
if (!parse_word(parser, word, error)) {
goto err;
}
dir->name = g_strdup(word->str);
skip_blanks(parser);
while (peek(parser) != '\0' && peek(parser) != '\n') {
if (peek(parser) == '{') {
parser->pos++;
skip_blanks(parser);
if (peek(parser) != '\n') {
parse_error(parser, error, "expected a line break after '{'");
goto err;
}
next_line(parser);
dir->children = parse_block(parser, true, error);
if (dir->children == NULL) {
goto err;
}
dir->end = parser->pos;
skip_blanks(parser);
if (peek(parser) != '\0' && peek(parser) != '\n') {
parser->pos = dir->end;
return dir;
}
break;
}
if (peek(parser) == '}') {
parse_error(parser, error, "unexpected '}'");
goto err;
}
size_t param_start = parser->pos;
g_string_truncate(word, 0);
if (!parse_word(parser, word, error)) {
goto err;
}
if (dir->params->len == 0) {
dir->criteria_start = param_start;
dir->criteria_end = parser->pos;
}
g_ptr_array_add(dir->params, g_strdup(word->str));
skip_blanks(parser);
}
next_line(parser);
dir->end = parser->pos;
return dir;
err:
kanshi_directive_free(dir);
return NULL;
}
static GPtrArray *parse_block(struct kanshi_parser *parser, bool nested,
GError **error) {
GPtrArray *dirs = g_ptr_array_new_with_free_func(kanshi_directive_free);
while (true) {
skip_blanks(parser);
char c = peek(parser);
if (c == '\n' || c == '#') {
next_line(parser);
} else if (c == '\0') {
if (nested) {
parse_error(parser, error, "expected '}'");
break;
}
return dirs;
} else if (c == '}') {
if (!nested) {
parse_error(parser, error, "unexpected '}'");
break;
}
parser->pos++;
return dirs;
} else {
struct kanshi_directive *dir = parse_directive(parser, error);
if (dir == NULL) {
break;
}
g_ptr_array_add(dirs, dir);
}
}
g_ptr_array_unref(dirs);
return NULL;
}
static const char *find_param(struct kanshi_directive *dir, const char *key) {
for (guint i = 1; i + 1 < dir->params->len; i++) {
if (strcmp(g_ptr_array_index(dir->params, i), key) == 0) {
return g_ptr_array_index(dir->params, i + 1);
}
}
for (guint i = 0; dir->children != NULL && i < dir->children->len; i++) {
struct kanshi_directive *child = g_ptr_array_index(dir->children, i);
if (strcmp(child->name, key) == 0 && child->params->len > 0) {
return g_ptr_array_index(child->params, 0);
}
}
return NULL;
}
static bool is_output(struct kanshi_directive *dir) {
return strcmp(dir->name, "output") == 0 && dir->params->len > 0;
}
static char *head_identifier(struct wd_head *head) {
return g_strdup_printf("%s %s %s",
head->make != NULL ? head->make : "Unknown",
head->model != NULL ? head->model : "Unknown",
head->serial_number != NULL ? head->serial_number : "Unknown");
}
static bool criteria_match(const char *criteria, struct wd_head *head) {
g_autofree char *identifier = head_identifier(head);
return strcmp(criteria, "*") == 0 || strcmp(criteria, head->name) == 0
|| fnmatch(criteria, identifier, 0) == 0;
}
static const char *resolve_alias(struct kanshi_config *config,
const char *criteria) {
if (criteria[0] != '$') {
return criteria;
}
for (guint i = 0; i < config->outputs->len; i++) {
struct kanshi_directive *dir = g_ptr_array_index(config->outputs, i);
const char *alias = find_param(dir, "alias");
if (alias != NULL && strcmp(alias, criteria) == 0) {
return g_ptr_array_index(dir->params, 0);
}
}
return NULL;
}
static bool match_profile(struct kanshi_config *config,
struct kanshi_directive *profile,
struct wd_head_config **heads, int num_heads,
struct kanshi_directive **matches) {
g_autoptr(GPtrArray) outputs = g_ptr_array_new();
for (guint i = 0; i < profile->children->len; i++) {
struct kanshi_directive *child = g_ptr_array_index(profile->children, i);
if (!is_output(child)) {
continue;
}
if (strcmp(g_ptr_array_index(child->params, 0), "*") == 0) {
g_ptr_array_add(outputs, child);
} else {
g_ptr_array_insert(outputs, 0, child);
}
}
if (outputs->len != (guint) num_heads) {
return false;
}
memset(matches, 0, num_heads * sizeof(*matches));
for (guint i = 0; i < outputs->len; i++) {
struct kanshi_directive *output = g_ptr_array_index(outputs, i);
const char *criteria = resolve_alias(config,
g_ptr_array_index(output->params, 0));
if (criteria == NULL) {
return false;
}
int j = 0;
while (j < num_heads
&& (matches[j] != NULL || !criteria_match(criteria, heads[j]->head))) {
j++;
}
if (j == num_heads) {
return false;
}
matches[j] = output;
}
return true;
}
static void append_word(GString *str, const char *word) {
if (word[0] != '\0' && strpbrk(word, " \t\"'{}\\#") == NULL) {
g_string_append(str, word);
return;
}
g_string_append_c(str, '"');
for (const char *c = word; *c != '\0'; c++) {
if (*c == '"' || *c == '\\') {
g_string_append_c(str, '\\');
}
g_string_append_c(str, *c);
}
g_string_append_c(str, '"');
}
static void append_criteria(GString *str, struct wd_head_config **heads,
int num_heads, struct wd_head *head) {
g_autofree char *identifier = head_identifier(head);
bool usable = head->make != NULL || head->model != NULL
|| head->serial_number != NULL;
if (identifier[0] == '$' || strchr(identifier, '\n') != NULL) {
usable = false;
}
for (int i = 0; usable && i < num_heads; i++) {
g_autofree char *other = head_identifier(heads[i]->head);
if (heads[i]->head != head && strcmp(identifier, other) == 0) {
usable = false;
}
}
if (!usable) {
append_word(str, head->name);
return;
}
g_autoptr(GString) pattern = g_string_new(NULL);
for (const char *c = identifier; *c != '\0'; c++) {
if (strchr("*?[\\", *c) != NULL) {
g_string_append_c(pattern, '\\');
}
g_string_append_c(pattern, *c);
}
append_word(str, pattern->str);
}
static const char *transform_name(enum wl_output_transform transform) {
switch (transform) {
case WL_OUTPUT_TRANSFORM_90:
return "90";
case WL_OUTPUT_TRANSFORM_180:
return "180";
case WL_OUTPUT_TRANSFORM_270:
return "270";
case WL_OUTPUT_TRANSFORM_FLIPPED:
return "flipped";
case WL_OUTPUT_TRANSFORM_FLIPPED_90:
return "flipped-90";
case WL_OUTPUT_TRANSFORM_FLIPPED_180:
return "flipped-180";
case WL_OUTPUT_TRANSFORM_FLIPPED_270:
return "flipped-270";
default:
return "normal";
}
}
static bool is_custom_mode(struct wd_head_config *output) {
struct wd_mode *mode;
wl_list_for_each(mode, &output->head->modes, link) {
if (mode->width == output->width && mode->height == output->height
&& mode->refresh == output->refresh) {
return false;
}
}
return true;
}
static void format_scale(char *buf, size_t size, double scale) {
wl_fixed_t fixed = wl_fixed_from_double(scale);
scale = wl_fixed_to_double(fixed);
for (int digits = 0; digits <= 8; digits++) {
char format[8];
snprintf(format, sizeof(format), "%%.%df", digits);
g_ascii_formatd(buf, size, format, scale);
if (wl_fixed_from_double(g_ascii_strtod(buf, NULL)) == fixed) {
break;
}
}
}
static void append_settings(GString *str, struct wd_head_config *output,
const char *adaptive_sync) {
if (!output->enabled) {
g_string_append(str, " disable");
} else {
g_string_append(str, " enable mode ");
if (is_custom_mode(output)) {
g_string_append(str, "--custom ");
}
g_string_append_printf(str, "%dx%d", output->width, output->height);
if (output->refresh > 0) {
char refresh[G_ASCII_DTOSTR_BUF_SIZE];
g_ascii_formatd(refresh, sizeof(refresh), "%.3f", output->refresh / 1000.);
g_string_append_printf(str, "@%sHz", refresh);
}
char scale[G_ASCII_DTOSTR_BUF_SIZE];
format_scale(scale, sizeof(scale), output->scale);
g_string_append_printf(str, " position %d,%d scale %s transform %s",
output->x, output->y, scale, transform_name(output->transform));
}
if (adaptive_sync != NULL) {
g_string_append(str, " adaptive_sync ");
append_word(str, adaptive_sync);
}
g_string_append_c(str, '\n');
}
static void append_comments(GString *str, const char *text,
struct kanshi_directive *dir) {
const char *end = text + dir->end;
const char *line = strchr(text + dir->name_start, '\n');
while (line != NULL && ++line < end) {
const char *next = strchr(line, '\n');
if (line[strspn(line, " \t")] == '#') {
g_string_append_len(str, line, (next != NULL ? next + 1 : end) - line);
}
line = next;
}
}
static void rewrite_profile(GString *str, const char *text,
struct kanshi_directive *profile, struct wd_head_config **heads,
int num_heads, struct kanshi_directive **matches) {
size_t pos = 0;
for (guint i = 0; i < profile->children->len; i++) {
struct kanshi_directive *child = g_ptr_array_index(profile->children, i);
g_string_append_len(str, text + pos, child->start - pos);
pos = child->end;
if (!is_output(child)) {
g_string_append_len(str, text + child->start, child->end - child->start);
continue;
}
for (int j = 0; j < num_heads; j++) {
if (matches[j] == child) {
append_comments(str, text, child);
g_string_append_len(str, text + child->start,
child->name_start - child->start);
g_string_append(str, "output ");
g_string_append_len(str, text + child->criteria_start,
child->criteria_end - child->criteria_start);
append_settings(str, heads[j], find_param(child, "adaptive_sync"));
}
}
}
g_string_append(str, text + pos);
}
static void append_profile(GString *str, struct wd_head_config **heads,
int num_heads) {
if (str->len > 0 && str->str[str->len - 1] != '\n') {
g_string_append_c(str, '\n');
}
if (str->len > 0) {
g_string_append_c(str, '\n');
}
g_string_append(str, "profile {\n");
for (int i = 0; i < num_heads; i++) {
g_string_append(str, "\toutput ");
append_criteria(str, heads, num_heads, heads[i]->head);
append_settings(str, heads[i], NULL);
}
g_string_append(str, "}\n");
}
static void kanshi_file_free(gpointer data) {
struct kanshi_file *file = data;
g_free(file->path);
g_free(file->text);
if (file->dirs != NULL) {
g_ptr_array_unref(file->dirs);
}
g_free(file);
}
static bool load_file(struct kanshi_config *config, const char *path,
bool included, int depth, GError **error);
static bool load_include(struct kanshi_config *config,
struct kanshi_directive *dir, int depth, GError **error) {
if (dir->params->len != 1) {
g_set_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"%s: include expects exactly one path", dir->file->path);
return false;
}
const char *pattern = g_ptr_array_index(dir->params, 0);
wordexp_t words;
int ret = wordexp(pattern, &words, WRDE_NOCMD | WRDE_UNDEF);
if (ret != 0) {
if (ret == WRDE_NOSPACE) {
wordfree(&words);
}
g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"%s: cannot expand include %s", dir->file->path, pattern);
return false;
}
bool ok = true;
for (size_t i = 0; ok && i < words.we_wordc; i++) {
if (!g_path_is_absolute(words.we_wordv[i])) {
g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"%s: include %s is relative to the directory kanshi runs in",
dir->file->path, words.we_wordv[i]);
ok = false;
} else {
ok = load_file(config, words.we_wordv[i], true, depth + 1, error);
}
}
wordfree(&words);
return ok;
}
static bool load_file(struct kanshi_config *config, const char *path,
bool included, int depth, GError **error) {
if (depth > INCLUDE_DEPTH_MAX) {
g_set_error(error, G_IO_ERROR, G_IO_ERROR_TOO_MANY_LINKS,
"%s: includes nested too deeply", path);
return false;
}
struct kanshi_file *file = g_new0(struct kanshi_file, 1);
file->path = g_strdup(path);
g_ptr_array_add(config->files, file);
if (!g_file_get_contents(path, &file->text, NULL, error)) {
if (included || !g_error_matches(*error, G_FILE_ERROR, G_FILE_ERROR_NOENT)) {
return false;
}
g_clear_error(error);
file->text = g_strdup("");
}
struct kanshi_parser parser = { .text = file->text, .line = 1 };
file->dirs = parse_block(&parser, false, error);
if (file->dirs == NULL) {
g_prefix_error(error, "%s: ", path);
return false;
}
for (guint i = 0; i < file->dirs->len; i++) {
struct kanshi_directive *dir = g_ptr_array_index(file->dirs, i);
dir->file = file;
if (strcmp(dir->name, "profile") == 0 && dir->children != NULL) {
g_ptr_array_add(config->profiles, dir);
} else if (is_output(dir)) {
g_ptr_array_add(config->outputs, dir);
} else if (strcmp(dir->name, "include") == 0
&& !load_include(config, dir, depth, error)) {
return false;
}
}
return true;
}
static char *update_config(struct kanshi_config *config,
struct wl_list *outputs, struct kanshi_file **file, GError **error) {
struct wd_head_config *heads[HEADS_MAX];
int num_heads = 0;
struct wd_head_config *output;
wl_list_for_each_reverse(output, outputs, link) {
if (num_heads == HEADS_MAX) {
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "too many outputs");
return NULL;
}
heads[num_heads++] = output;
}
if (num_heads == 0) {
g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED, "no outputs");
return NULL;
}
GString *str = g_string_new(NULL);
struct kanshi_directive *matches[HEADS_MAX];
for (guint i = 0; i < config->profiles->len; i++) {
struct kanshi_directive *profile = g_ptr_array_index(config->profiles, i);
if (match_profile(config, profile, heads, num_heads, matches)) {
*file = profile->file;
rewrite_profile(str, (*file)->text, profile, heads, num_heads, matches);
return g_string_free(str, FALSE);
}
}
*file = g_ptr_array_index(config->files, 0);
g_string_append(str, (*file)->text);
append_profile(str, heads, num_heads);
return g_string_free(str, FALSE);
}
static char *resolve_links(const char *path) {
char *current = g_strdup(path);
for (int i = 0; i < 40; i++) {
char *target = g_file_read_link(current, NULL);
if (target == NULL) {
break;
}
if (!g_path_is_absolute(target)) {
g_autofree char *dir = g_path_get_dirname(current);
g_autofree char *relative = target;
target = g_build_filename(dir, relative, NULL);
}
g_free(current);
current = target;
}
return current;
}
static bool write_config(const char *config_path, const char *contents,
GError **error) {
g_autofree char *path = resolve_links(config_path);
g_autofree char *dir = g_path_get_dirname(path);
if (g_mkdir_with_parents(dir, 0755) != 0) {
int err = errno;
g_set_error(error, G_FILE_ERROR, g_file_error_from_errno(err),
"%s: %s", dir, g_strerror(err));
return false;
}
return g_file_set_contents(path, contents, -1, error);
}
static void reload_kanshi(void) {
g_autofree char *kanshictl = g_find_program_in_path("kanshictl");
if (kanshictl == NULL) {
return;
}
char *argv[] = { kanshictl, "reload", NULL };
g_autoptr(GError) error = NULL;
if (!g_spawn_async(NULL, argv, NULL, 0, NULL, NULL, NULL, &error)) {
fprintf(stderr, "Failed to run kanshictl: %s\n", error->message);
}
}
void wd_store_config(struct wd_state *state, struct wl_list *outputs) {
g_autofree char *path = get_config_path();
g_autoptr(GPtrArray) files = g_ptr_array_new_with_free_func(kanshi_file_free);
g_autoptr(GPtrArray) profiles = g_ptr_array_new();
g_autoptr(GPtrArray) global_outputs = g_ptr_array_new();
struct kanshi_config config = { files, profiles, global_outputs };
struct kanshi_file *file = NULL;
g_autofree char *updated = NULL;
g_autoptr(GError) error = NULL;
if (!load_file(&config, path, false, 0, &error)) {
goto err;
}
updated = update_config(&config, outputs, &file, &error);
if (updated == NULL || !write_config(file->path, updated, &error)) {
goto err;
}
reload_kanshi();
return;
err:;
g_autofree char *message = g_strdup_printf(
"Could not save the kanshi config: %s", error->message);
wd_ui_show_error(state, message);
}
+28
View File
@@ -108,6 +108,7 @@ struct wd_head {
uint32_t id;
char *name, *description;
char *make, *model, *serial_number;
int32_t phys_width, phys_height; // mm
struct wl_list modes;
@@ -187,12 +188,16 @@ struct wd_state {
struct wl_shm *shm;
struct wl_list heads;
struct wl_list outputs;
struct wl_list pending_configs;
uint32_t serial;
bool apply_pending;
bool apply_manual;
bool autoapply;
bool capture;
bool show_overlay;
bool save_kanshi_config;
struct wl_list *kanshi_snapshot;
double zoom;
unsigned int apply_idle;
@@ -280,6 +285,19 @@ void wd_add_output_management_listener(struct wd_state *state, struct wl_display
*/
void wd_apply_state(struct wd_state *state, struct wl_list *new_outputs, struct wl_display *display);
#ifdef WITH_KANSHI
/*
* Saves the layout of the last auto-apply to the kanshi config, unless it
* has been saved or dropped already.
*/
void wd_save_kanshi_snapshot(struct wd_state *state);
/*
* Forgets the layout of the last auto-apply without saving it.
*/
void wd_drop_kanshi_snapshot(struct wd_state *state);
#endif
/*
* Queues capture of the next frame of all screens.
*/
@@ -354,4 +372,14 @@ void wd_redraw_overlay(struct wd_output *output);
*/
void wd_destroy_overlay(struct wd_output *output);
// SPDX-SnippetBegin
// SPDX-License-Identifier: MIT
// SPDX-SnippetCopyrightText: 2024-2025 Jason André Charles Gantner
#ifdef WITH_KANSHI
/*
* Updates kanshi config
*/
void wd_store_config(struct wd_state *state, struct wl_list *outputs);
#endif
// SPDX-SnippetEnd
#endif