diff --git a/README.md b/README.md index 79bc825..9949216 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/meson.build b/meson.build index f9dec35..913acea 100644 --- a/meson.build +++ b/meson.build @@ -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') diff --git a/meson_options.txt b/meson_options.txt new file mode 100644 index 0000000..de78db1 --- /dev/null +++ b/meson_options.txt @@ -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') diff --git a/protocol/wlr-output-management-unstable-v1.xml b/protocol/wlr-output-management-unstable-v1.xml index 35f7ca4..bf0cc93 100644 --- a/protocol/wlr-output-management-unstable-v1.xml +++ b/protocol/wlr-output-management-unstable-v1.xml @@ -39,7 +39,7 @@ interface version number is reset. - + This interface is a manager that allows reading and writing the current output device configuration. @@ -115,7 +115,7 @@ - + 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 @@ - + 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. + + + + + 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. + + + + + + + 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. + + + + + + + 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. + + + - + This object describes an output mode. @@ -305,7 +376,7 @@ - + This object is used by the client to describe a full output configuration. @@ -423,7 +494,7 @@ - + This object is used by the client to update a single head's configuration. diff --git a/resources/wdisplays.gschema.xml b/resources/wdisplays.gschema.xml index 176072c..540885b 100644 --- a/resources/wdisplays.gschema.xml +++ b/resources/wdisplays.gschema.xml @@ -10,5 +10,8 @@ true + + false + diff --git a/src/config.h.in b/src/config.h.in index 47a8834..f62aeab 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -8,4 +8,7 @@ #define WDISPLAYS_VERSION "@version@" #define WDISPLAYS_RESOURCE_PREFIX "@resource_prefix@" +// Features +#mesondefine WITH_KANSHI + #endif diff --git a/src/main.c b/src/main.c index 7e379c5..b538875 100644 --- a/src/main.c +++ b/src/main.c @@ -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); diff --git a/src/meson.build b/src/meson.build index 223f967..5077513 100644 --- a/src/meson.build +++ b/src/meson.build @@ -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, diff --git a/src/outputs.c b/src/outputs.c index 46cb6b8..5255178 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -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; } diff --git a/src/store.c b/src/store.c new file mode 100644 index 0000000..2e8ab97 --- /dev/null +++ b/src/store.c @@ -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 +#include +#include +#include + +#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); +} diff --git a/src/wdisplays.h b/src/wdisplays.h index 84a31f4..e14f875 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -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