From 790e33e9d81587320e20e22832ad3e2900b867d3 Mon Sep 17 00:00:00 2001 From: Shaochang Tan <478710209@qq.com> Date: Sun, 8 Oct 2023 09:09:22 +0200 Subject: [PATCH 01/38] add new feature: autosave config profile for kanshi. --- .vscode/settings.json | 7 ++ src/meson.build | 1 + src/outputs.c | 7 ++ src/store.c | 271 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 src/store.c diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dcb4ccb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "files.associations": { + "wdisplays.h": "c", + "wayland-client-protocol.h": "c", + "path": "c" + } +} \ No newline at end of file diff --git a/src/meson.build b/src/meson.build index 08830e9..8527452 100644 --- a/src/meson.build +++ b/src/meson.build @@ -20,6 +20,7 @@ executable( 'outputs.c', 'overlay.c', 'render.c', + 'store.c', resources, ], dependencies : [ diff --git a/src/outputs.c b/src/outputs.c index 024a686..f4ac15c 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -28,6 +28,8 @@ #include "wlr-screencopy-unstable-v1-client-protocol.h" #include "wlr-layer-shell-unstable-v1-client-protocol.h" +extern int store_config(struct wl_list *outputs); + static void noop() { // This space is intentionally left blank } @@ -52,6 +54,11 @@ static void config_handle_succeeded(void *data, struct wd_pending_config *pending = data; zwlr_output_configuration_v1_destroy(config); wd_ui_apply_done(pending->state, pending->outputs); + if (store_config(pending->outputs) == 0) + { + wd_ui_show_error(pending->state, + "Change was applied successfully and config was saved."); + } destroy_pending(pending); } diff --git a/src/store.c b/src/store.c new file mode 100644 index 0000000..a2e1bf9 --- /dev/null +++ b/src/store.c @@ -0,0 +1,271 @@ +#include "wdisplays.h" +#include +#include +#include +#include +#include +#include +#define MAX_NAME_LENGTH 256 +#define MAX_MONITORS_NUM 10 +struct wd_head_config; +struct profile_line { + int start; + int end; +}; +char *get_config_file_path() { + // 获取用户的主目录路径 + const char *homeDir = getenv("HOME"); + + if (homeDir == NULL) { + perror("Cannot load $HOME env."); + return NULL; + } + + // 构建默认的配置文件路径 + char defaultPath[256]; // 假设文件路径不超过256个字符 + snprintf(defaultPath, sizeof(defaultPath), "%s/.config/kanshi/config", homeDir); + + // 尝试打开并读取 $HOME/.config/wdisplays/config 文件 + char wdisplaysPath[256]; + snprintf(wdisplaysPath, sizeof(wdisplaysPath), "%s/.config/wdisplays/config", homeDir); + + FILE *wdisplaysFile = fopen(wdisplaysPath, "r"); + if (wdisplaysFile != NULL) { + char line[256]; // 假设行的长度不超过256个字符 + + // 逐行读取文件,查找 "store PATH" 配置项 + while (fgets(line, sizeof(line), wdisplaysFile) != NULL) { + if (strstr(line, "store_path") != NULL) { + // 找到 "store PATH" 配置项,提取路径 + char *pathStart = strchr(line, '='); + if (pathStart != NULL) { + pathStart++; // 跳过等号 + char *pathEnd = strchr(pathStart, '\n'); + if (pathEnd != NULL) { + *pathEnd = '\0'; // 去除换行符 + fclose(wdisplaysFile); + return strdup(pathStart); // 返回提取的路径 + } + } + } + } + + fclose(wdisplaysFile); + } + + // 如果没有找到 "store PATH" 配置项,则返回默认路径 + return strdup(defaultPath); +} + +struct profile_line match(char **descriptions, int num, char *filename) { + struct profile_line matched_profile; + matched_profile.start = -1; + matched_profile.end = -1; + FILE *configFile = fopen(filename, "r"); + if (configFile == NULL) { + perror("File open failed."); + return matched_profile; + } + // 缓冲区用于存储文件行 + char buffer[1024]; + char profileName[MAX_NAME_LENGTH]; + int profileStartLine = 0; // 记录匹配到的profile的起始行号 + int profileEndLine = 0; // 记录匹配到的profile的结束行号 + + int lineCount = 0; // 用于记录当前行号 + + while (fgets(buffer, sizeof(buffer), configFile) != NULL) { + lineCount++; // 增加行号 + + // 检查是否包含 "profile" 关键字 + if (strstr(buffer, "profile") != NULL) { + // 从当前行提取 profile 名称 + sscanf(buffer, "profile %s {", profileName); + + // 标记当前 profile 是否匹配 + int profileMatched = 0; + + // 记录匹配到的profile的起始行号 + profileStartLine = lineCount; + + // 遍历 profile 中的输出行 + while (fgets(buffer, sizeof(buffer), configFile) != NULL) { + lineCount++; // 增加行号 + + // 检查是否到达当前 profile 的末尾 + if (buffer[0] == '}') { + // 记录匹配到的profile的结束行号 + profileEndLine = lineCount; + break; // 退出当前 profile + } + char outputName[MAX_NAME_LENGTH]; + // 从当前行提取输出名称 + char *trimmedBuffer = buffer; + while (isspace(*trimmedBuffer)) { + trimmedBuffer++; + } + sscanf(trimmedBuffer, "output \"%99[^\"]\"", outputName); + + // 检查是否匹配 + int matched = 0; + for (int i = 0; descriptions[i] != NULL; i++) { + if (strcmp(outputName, descriptions[i]) == 0) { + matched = 1; + profileMatched++; + break; + } + } + + if (!matched) { + // 如果有任何一个输出不匹配,则标记为不匹配 + profileMatched = 0; + break; + } + } + + if (profileMatched == num) { + printf("Matched profile:%s\n", profileName); + printf("Start line:%d\n", profileStartLine); + matched_profile.start = profileStartLine; + printf("End line:%d\n", profileEndLine); + matched_profile.end = profileEndLine; + + fclose(configFile); + return matched_profile; + } + } + } + + // 关闭配置文件 + fclose(configFile); + + printf("Cannot find exsiting profile to match\n"); + return matched_profile; +} + +int store_config(struct wl_list *outputs) { + char *file_name = get_config_file_path(); + char tmp_file_name[256]; + sprintf(tmp_file_name,"%s.tmp",file_name); + + char *descriptions[MAX_MONITORS_NUM]; + for (int i = 0; i < MAX_MONITORS_NUM; i++) { + descriptions[i] = NULL; + } + + char *outputConfigs[MAX_MONITORS_NUM]; + for (int i = 0; i < MAX_MONITORS_NUM; i++) { + outputConfigs[i] = (char *)malloc(MAX_NAME_LENGTH); + } + + struct wd_head_config *output; + int description_index = 0; + wl_list_for_each(output, outputs, link) { + struct wd_head *head = output->head; + + // for transform + char *trans_str = (char *)malloc(15 * sizeof(char)); + switch (output->transform) { + case WL_OUTPUT_TRANSFORM_NORMAL: + strcpy(trans_str, "normal"); + break; + case WL_OUTPUT_TRANSFORM_90: + strcpy(trans_str, "90"); + break; + case WL_OUTPUT_TRANSFORM_180: + strcpy(trans_str, "180"); + break; + case WL_OUTPUT_TRANSFORM_270: + strcpy(trans_str, "270"); + break; + case WL_OUTPUT_TRANSFORM_FLIPPED_90: + strcpy(trans_str, "flipped-90"); + break; + case WL_OUTPUT_TRANSFORM_FLIPPED_180: + strcpy(trans_str, "flipped-180"); + break; + case WL_OUTPUT_TRANSFORM_FLIPPED_270: + strcpy(trans_str, "flipped-270"); + break; + default: + strcpy(trans_str, "normal"); + break; + } + + if (description_index < MAX_MONITORS_NUM) { + descriptions[description_index] = strdup(head->description); + + sprintf( + outputConfigs[description_index], + "output \"%s\" position %d,%d mode %dx%d@%.4f scale %.2f transform %s", + head->description, output->x, output->y, output->width, + output->height, output->refresh / 1.0e3, output->scale, trans_str); + description_index++; + } else { + printf("Too many monitor!"); + return 1; + } + + free(trans_str); + } + + int num_of_monitors = description_index; + + struct profile_line matched_profile; + matched_profile = match(descriptions, num_of_monitors, file_name); + + if (matched_profile.start == -1) { + FILE *file = fopen(file_name, "a"); + if (file == NULL) { + perror("File open failed."); + return 1; + } + fprintf(file, "\nprofile {\n"); + for (int i = 0; i= matched_profile.start && _line < matched_profile.end - 1) { + if(_i_output>=num_of_monitors){ + perror("Null pointer"); + fclose(tmp); + fclose(file); + return 1; + } + fprintf(tmp," %s\n",outputConfigs[_i_output]); + free(outputConfigs[_i_output]); + + _i_output++; + } else{ + fprintf(tmp,"%s",_buffer); + } + _line++; + } + fclose(file); + fclose(tmp); + + remove(file_name); + rename(tmp_file_name, file_name); + } + + return 0; +} \ No newline at end of file From 86c1a529c1bc2389bee1c8672230ea6489f3324d Mon Sep 17 00:00:00 2001 From: Shaochang Tan <478710209@qq.com> Date: Sun, 8 Oct 2023 09:21:01 +0200 Subject: [PATCH 02/38] update readme --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79bc825..b406db8 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_always pkill kanshi +exec_always kanshi +``` ### How do I add support to my compositor? From 397ea389688992398918af7b855c0d092e743097 Mon Sep 17 00:00:00 2001 From: Shaochang Tan <478710209@qq.com> Date: Fri, 31 May 2024 20:45:27 +0200 Subject: [PATCH 03/38] 1. remove vscode settings 2. check $XDG_CONFIG_HOME before $HOME 3. all MAX SIZE now use platform-based macro from limits.h rather than magic number --- .gitignore | 1 + .vscode/settings.json | 7 --- README.md | 4 +- src/store.c | 124 ++++++++++++++++++++++-------------------- 4 files changed, 69 insertions(+), 67 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 48835df..2122d6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /build/ +.vscode *.user diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index dcb4ccb..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files.associations": { - "wdisplays.h": "c", - "wayland-client-protocol.h": "c", - "path": "c" - } -} \ No newline at end of file diff --git a/README.md b/README.md index b406db8..9949216 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ if there is already a profile for the same monitors combination, the change will existing one. you can add kanshi autostart to your sway config: ``` -exec_always pkill kanshi -exec_always kanshi +exec kanshi +exec_always kanshictl reload ``` ### How do I add support to my compositor? diff --git a/src/store.c b/src/store.c index a2e1bf9..6dab784 100644 --- a/src/store.c +++ b/src/store.c @@ -5,6 +5,8 @@ #include #include #include +#include +#include #define MAX_NAME_LENGTH 256 #define MAX_MONITORS_NUM 10 struct wd_head_config; @@ -13,47 +15,50 @@ struct profile_line { int end; }; char *get_config_file_path() { - // 获取用户的主目录路径 - const char *homeDir = getenv("HOME"); - - if (homeDir == NULL) { - perror("Cannot load $HOME env."); - return NULL; + char defaultPath[PATH_MAX]; // platform based marco PATH_MAX + char wdisplaysPath[PATH_MAX]; + // if $XDG_CONFIG_HOME is set, use it + { + const char *configDir = getenv("XDG_CONFIG_HOME"); + char defaultConfigDir[PATH_MAX]; + if (configDir == NULL) { + const char *homeDir = getenv("HOME"); + if (homeDir == NULL) { + perror("Cannot find home directory"); + return NULL; + } + snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s/.config", homeDir); + } else { + snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s", configDir); + } + snprintf(defaultPath, sizeof(defaultPath), "%s/kanshi/config", defaultConfigDir); + snprintf(wdisplaysPath, sizeof(wdisplaysPath), "%s/wdisplays/config", defaultConfigDir); } - // 构建默认的配置文件路径 - char defaultPath[256]; // 假设文件路径不超过256个字符 - snprintf(defaultPath, sizeof(defaultPath), "%s/.config/kanshi/config", homeDir); - - // 尝试打开并读取 $HOME/.config/wdisplays/config 文件 - char wdisplaysPath[256]; - snprintf(wdisplaysPath, sizeof(wdisplaysPath), "%s/.config/wdisplays/config", homeDir); - FILE *wdisplaysFile = fopen(wdisplaysPath, "r"); if (wdisplaysFile != NULL) { - char line[256]; // 假设行的长度不超过256个字符 + char line[LINE_MAX]; // LINE_MAX is a platform based marco - // 逐行读取文件,查找 "store PATH" 配置项 + // try to match "store_path" term while (fgets(line, sizeof(line), wdisplaysFile) != NULL) { if (strstr(line, "store_path") != NULL) { - // 找到 "store PATH" 配置项,提取路径 + // if found, extract path char *pathStart = strchr(line, '='); if (pathStart != NULL) { - pathStart++; // 跳过等号 + pathStart++; // skip '=' char *pathEnd = strchr(pathStart, '\n'); if (pathEnd != NULL) { - *pathEnd = '\0'; // 去除换行符 + *pathEnd = '\0'; // replace '\n' with '\0' fclose(wdisplaysFile); - return strdup(pathStart); // 返回提取的路径 + return strdup(pathStart); // return path } } } } - fclose(wdisplaysFile); } - // 如果没有找到 "store PATH" 配置项,则返回默认路径 + // if store_path is not found in wdisplays config file, return default path return strdup(defaultPath); } @@ -61,73 +66,72 @@ struct profile_line match(char **descriptions, int num, char *filename) { struct profile_line matched_profile; matched_profile.start = -1; matched_profile.end = -1; + // -1 means not found FILE *configFile = fopen(filename, "r"); if (configFile == NULL) { perror("File open failed."); return matched_profile; } - // 缓冲区用于存储文件行 - char buffer[1024]; + // buffer to store each line + char buffer[LINE_MAX]; char profileName[MAX_NAME_LENGTH]; - int profileStartLine = 0; // 记录匹配到的profile的起始行号 - int profileEndLine = 0; // 记录匹配到的profile的结束行号 + int profileStartLine = 0; // mark the start line of matched profile + int profileEndLine = 0; // mark the end line of matched profile - int lineCount = 0; // 用于记录当前行号 + int lineCount = 0; // current line number while (fgets(buffer, sizeof(buffer), configFile) != NULL) { - lineCount++; // 增加行号 + lineCount++; - // 检查是否包含 "profile" 关键字 + // check if "profile" keyword is in the line if (strstr(buffer, "profile") != NULL) { - // 从当前行提取 profile 名称 + // extract profile name sscanf(buffer, "profile %s {", profileName); - // 标记当前 profile 是否匹配 - int profileMatched = 0; + // the number of matched outputs + uint32_t profileMatchedNum = 0; - // 记录匹配到的profile的起始行号 + // record the start line of the profile profileStartLine = lineCount; - // 遍历 profile 中的输出行 while (fgets(buffer, sizeof(buffer), configFile) != NULL) { - lineCount++; // 增加行号 + lineCount++; - // 检查是否到达当前 profile 的末尾 + // check if the profile ends if (buffer[0] == '}') { - // 记录匹配到的profile的结束行号 profileEndLine = lineCount; - break; // 退出当前 profile + break; } char outputName[MAX_NAME_LENGTH]; // 从当前行提取输出名称 char *trimmedBuffer = buffer; while (isspace(*trimmedBuffer)) { - trimmedBuffer++; + trimmedBuffer++; // skip leading spaces } - sscanf(trimmedBuffer, "output \"%99[^\"]\"", outputName); + sscanf(trimmedBuffer, "output \"%99[^\"]\"", outputName); // extract output name - // 检查是否匹配 - int matched = 0; + // check if the output name is in the descriptions + bool matched = false; for (int i = 0; descriptions[i] != NULL; i++) { if (strcmp(outputName, descriptions[i]) == 0) { - matched = 1; - profileMatched++; + matched = true; + profileMatchedNum++; break; } } if (!matched) { - // 如果有任何一个输出不匹配,则标记为不匹配 - profileMatched = 0; + // if any output is not matched, break + profileMatchedNum = 0; break; } } - if (profileMatched == num) { - printf("Matched profile:%s\n", profileName); - printf("Start line:%d\n", profileStartLine); + if (profileMatchedNum == num) { + printf("Matched profile:%s\n", profileName); + printf("Start line:%d\n", profileStartLine); matched_profile.start = profileStartLine; - printf("End line:%d\n", profileEndLine); + printf("End line:%d\n", profileEndLine); matched_profile.end = profileEndLine; fclose(configFile); @@ -136,16 +140,14 @@ struct profile_line match(char **descriptions, int num, char *filename) { } } - // 关闭配置文件 fclose(configFile); - - printf("Cannot find exsiting profile to match\n"); + printf("Cannot find existing profile to match\n"); return matched_profile; } int store_config(struct wl_list *outputs) { char *file_name = get_config_file_path(); - char tmp_file_name[256]; + char tmp_file_name[PATH_MAX]; sprintf(tmp_file_name,"%s.tmp",file_name); char *descriptions[MAX_MONITORS_NUM]; @@ -194,7 +196,7 @@ int store_config(struct wl_list *outputs) { if (description_index < MAX_MONITORS_NUM) { descriptions[description_index] = strdup(head->description); - + // write output config in given format sprintf( outputConfigs[description_index], "output \"%s\" position %d,%d mode %dx%d@%.4f scale %.2f transform %s", @@ -202,7 +204,8 @@ int store_config(struct wl_list *outputs) { output->height, output->refresh / 1.0e3, output->scale, trans_str); description_index++; } else { - printf("Too many monitor!"); + free(trans_str); + printf("Too many monitor! 10 is the"); return 1; } @@ -215,9 +218,11 @@ int store_config(struct wl_list *outputs) { matched_profile = match(descriptions, num_of_monitors, file_name); if (matched_profile.start == -1) { + // append new profile FILE *file = fopen(file_name, "a"); if (file == NULL) { perror("File open failed."); + free(file_name); return 1; } fprintf(file, "\nprofile {\n"); @@ -228,19 +233,21 @@ int store_config(struct wl_list *outputs) { fprintf(file, "}"); fclose(file); } else if (matched_profile.start < matched_profile.end) { - // rewrite correspondece lines + // rewrite correspondence lines FILE *file = fopen(file_name, "r"); if (file == NULL) { perror("File open failed."); + free(file_name); return 1; } FILE *tmp = fopen(tmp_file_name, "w"); if (tmp == NULL) { perror("Tmp file cannot be created."); fclose(file); + free(file_name); return 1; } - char _buffer[1024]; + char _buffer[LINE_MAX]; int _line = 0; int _i_output = 0; while (fgets(_buffer, sizeof(_buffer), file) != NULL) { @@ -265,6 +272,7 @@ int store_config(struct wl_list *outputs) { remove(file_name); rename(tmp_file_name, file_name); + free(file_name); } return 0; From 3a07d4174c2ac78f9b7618a9d422748acd72b4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jason=20Andr=C3=A9=20Charles=20Gantner?= Date: Tue, 1 Jul 2025 00:40:16 +0200 Subject: [PATCH 04/38] PR#11 improvment proposal (#1) * check config path only on startup * use HEADS_MAX from wdisplays.h * remove useless strcpy * update readme and meson syntax * clang-format --- src/store.c | 320 ++++++++++++++++++++++++------------------------ src/wdisplays.h | 15 +++ 2 files changed, 172 insertions(+), 163 deletions(-) diff --git a/src/store.c b/src/store.c index 6dab784..f2c43c2 100644 --- a/src/store.c +++ b/src/store.c @@ -1,164 +1,181 @@ #include "wdisplays.h" #include +#include #include +#include #include #include #include #include -#include -#include + #define MAX_NAME_LENGTH 256 -#define MAX_MONITORS_NUM 10 + struct wd_head_config; + struct profile_line { int start; int end; }; -char *get_config_file_path() { - char defaultPath[PATH_MAX]; // platform based marco PATH_MAX - char wdisplaysPath[PATH_MAX]; - // if $XDG_CONFIG_HOME is set, use it - { - const char *configDir = getenv("XDG_CONFIG_HOME"); - char defaultConfigDir[PATH_MAX]; + +typedef enum { Looking_for_profile, Looking_for_outputs, Found } parser_states; + +char *wd_get_config_file_path() { + char kanshiConfigPath[PATH_MAX]; + char wdisplaysPath[PATH_MAX]; + char defaultConfigDir[PATH_MAX]; + // if $XDG_CONFIG_HOME is set, use it + { + char *configDir = getenv("XDG_CONFIG_HOME"); + if (configDir == NULL) { // fallback to $HOME + configDir = getenv("HOME"); if (configDir == NULL) { - const char *homeDir = getenv("HOME"); - if (homeDir == NULL) { - perror("Cannot find home directory"); - return NULL; - } - snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s/.config", homeDir); - } else { - snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s", configDir); + dprintf(2, "%s:%i:%s(): Cannot find $XDG_CONFIG_HOME nor $HOME directories", __FILE__, __LINE__, __func__); + return NULL; + } else { // configdir is $HOME/config + snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s/.config", configDir); } - snprintf(defaultPath, sizeof(defaultPath), "%s/kanshi/config", defaultConfigDir); - snprintf(wdisplaysPath, sizeof(wdisplaysPath), "%s/wdisplays/config", defaultConfigDir); + } else { // configDir is $XDG_CONFIG_HOME + snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s", configDir); } + } - FILE *wdisplaysFile = fopen(wdisplaysPath, "r"); - if (wdisplaysFile != NULL) { - char line[LINE_MAX]; // LINE_MAX is a platform based marco + // set default kanshi config path + snprintf(kanshiConfigPath, sizeof(kanshiConfigPath), "%s/kanshi/config", defaultConfigDir); - // try to match "store_path" term - while (fgets(line, sizeof(line), wdisplaysFile) != NULL) { - if (strstr(line, "store_path") != NULL) { - // if found, extract path - char *pathStart = strchr(line, '='); - if (pathStart != NULL) { - pathStart++; // skip '=' - char *pathEnd = strchr(pathStart, '\n'); - if (pathEnd != NULL) { - *pathEnd = '\0'; // replace '\n' with '\0' - fclose(wdisplaysFile); - return strdup(pathStart); // return path - } - } - } - } - fclose(wdisplaysFile); - } + // look for store_path in wdisplays.conf + snprintf(wdisplaysPath, sizeof(wdisplaysPath), "%s/wdisplays.conf", defaultConfigDir); - // if store_path is not found in wdisplays config file, return default path - return strdup(defaultPath); + FILE *wdisplaysFile = fopen(wdisplaysPath, "r"); + if (wdisplaysFile != NULL) { + char line[LINE_MAX]; // LINE_MAX is a platform-dependendant macro + + // try to match "store_path" term + while (fgets(line, sizeof(line), wdisplaysFile) != NULL) { + if (strstr(line, "store_path") != NULL) { + // if found, extract path + char *pathStart = strchr(line, '='); + if (pathStart != NULL) { + pathStart++; // skip '=' + while (isspace(*pathStart)) pathStart++; // skip spaces between '=' and the start of the path + char *pathEnd = strchr(pathStart, '\n'); + size_t pathLen; + if (pathEnd != NULL) pathLen = pathEnd - pathStart; + else // store_path= is the last line and there's no newline at the end of the file + pathLen = strnlen(pathStart, PATH_MAX); + // save path + strncpy(kanshiConfigPath, pathStart, pathLen); + } else + ; // store_path was not followed by an equal sign on this line + } else + ; // this line does not contain store_path + } // reached end of file + fclose(wdisplaysFile); + } else { // can't open config file + dprintf(2, "%s:%i:%s(): Can't open %s : ", __FILE__, __LINE__, __func__, wdisplaysPath); + perror(NULL); + } + + // look for WDISPLAYS_KANSHI_CONFIG + { + char *envKanshiConf = getenv("WDISPLAYS_KANSHI_CONFIG"); + if (envKanshiConf != NULL) strncpy(kanshiConfigPath, envKanshiConf, sizeof(kanshiConfigPath)); + else + ; + } + char *finalPath = strndup(kanshiConfigPath, PATH_MAX); + if (finalPath == NULL) { + dprintf(2, "%s:%i:%s(): ", __FILE__, __LINE__, __func__); + perror("Failed to allocate memory for kanshi config path"); + } + return finalPath; } -struct profile_line match(char **descriptions, int num, char *filename) { +struct profile_line match(char **descriptions, int num, const char *filename) { struct profile_line matched_profile; matched_profile.start = -1; - matched_profile.end = -1; + matched_profile.end = -1; // -1 means not found - FILE *configFile = fopen(filename, "r"); + FILE *configFile = fopen(filename, "r"); if (configFile == NULL) { - perror("File open failed."); + dprintf(2, "%s:%i:%s(): Can't open %s : ", __FILE__, __LINE__, __func__, filename); + perror(NULL); return matched_profile; } // buffer to store each line char buffer[LINE_MAX]; - char profileName[MAX_NAME_LENGTH]; + char *profileName; int profileStartLine = 0; // mark the start line of matched profile - int profileEndLine = 0; // mark the end line of matched profile + int profileEndLine = 0; // mark the end line of matched profile - int lineCount = 0; // current line number - - while (fgets(buffer, sizeof(buffer), configFile) != NULL) { + int lineCount = 0; // current line number + uint32_t profileMatchedNum = 0; // current number of matched outputs + parser_states ps = Looking_for_profile; // current state of the parser + while (ps != Found && fgets(buffer, sizeof(buffer), configFile) != NULL) { lineCount++; + switch (ps) { + case Found: break; // unreachable code - // check if "profile" keyword is in the line - if (strstr(buffer, "profile") != NULL) { - // extract profile name - sscanf(buffer, "profile %s {", profileName); - - // the number of matched outputs - uint32_t profileMatchedNum = 0; - - // record the start line of the profile - profileStartLine = lineCount; - - while (fgets(buffer, sizeof(buffer), configFile) != NULL) { - lineCount++; + case Looking_for_profile:; + // check if "profile" keyword is in the line and remember its position + char *pstart = strstr(buffer, "profile "); + if (pstart != NULL) { + pstart += 7; + char *pend = strchr(pstart, '{'); // find the end of the profile name + while (isspace(*pend)) pend--; + size_t pnsize = pend - pstart; + // use strndup to extract it without being size constrained + profileName = strndup(pstart, pnsize); + // record the start line of the profile + profileStartLine = lineCount; + ps = Looking_for_outputs; + } + break; + case Looking_for_outputs: // check if the profile ends if (buffer[0] == '}') { profileEndLine = lineCount; - break; - } - char outputName[MAX_NAME_LENGTH]; - // 从当前行提取输出名称 - char *trimmedBuffer = buffer; - while (isspace(*trimmedBuffer)) { - trimmedBuffer++; // skip leading spaces - } - sscanf(trimmedBuffer, "output \"%99[^\"]\"", outputName); // extract output name - - // check if the output name is in the descriptions - bool matched = false; - for (int i = 0; descriptions[i] != NULL; i++) { - if (strcmp(outputName, descriptions[i]) == 0) { - matched = true; + if (profileMatchedNum == num) ps = Found; + } else { + char *on_start = strstr(buffer, "output"); + on_start = strchr(on_start, '"'); + on_start++; + char *on_end = strchr(on_start, '"'); + char *outputName = strndup(on_start, on_end - on_start); + // check if the output name is in the descriptions + int i = 0; + while (descriptions[i] != NULL && strcmp(outputName, descriptions[i])) i++; + if (descriptions[i] != NULL) { profileMatchedNum++; - break; + } else { + // if any output is not matched, break + profileMatchedNum = 0; + ps = Looking_for_profile; } } - - if (!matched) { - // if any output is not matched, break - profileMatchedNum = 0; - break; - } - } - - if (profileMatchedNum == num) { - printf("Matched profile:%s\n", profileName); - printf("Start line:%d\n", profileStartLine); - matched_profile.start = profileStartLine; - printf("End line:%d\n", profileEndLine); - matched_profile.end = profileEndLine; - - fclose(configFile); - return matched_profile; - } + break; } } - fclose(configFile); - printf("Cannot find existing profile to match\n"); + if (ps == Found) { + printf("Matched profile:%s\n", profileName); + printf("Start line:%d\nEnd line:%d\n", profileStartLine, profileEndLine); + matched_profile.start = profileStartLine; + matched_profile.end = profileEndLine; + } else dprintf(2, "%s:%i:%s(): Cannot find existing profile to match\n", __FILE__, __LINE__, __func__); return matched_profile; } -int store_config(struct wl_list *outputs) { - char *file_name = get_config_file_path(); +int wd_store_config(struct wl_list *outputs) { + const char *file_name = wd_get_kanshi_config(); char tmp_file_name[PATH_MAX]; - sprintf(tmp_file_name,"%s.tmp",file_name); + sprintf(tmp_file_name, "%s.tmp", file_name); - char *descriptions[MAX_MONITORS_NUM]; - for (int i = 0; i < MAX_MONITORS_NUM; i++) { - descriptions[i] = NULL; - } + char *descriptions[HEADS_MAX]; + for (int i = 0; i < HEADS_MAX; i++) descriptions[i] = NULL; - char *outputConfigs[MAX_MONITORS_NUM]; - for (int i = 0; i < MAX_MONITORS_NUM; i++) { - outputConfigs[i] = (char *)malloc(MAX_NAME_LENGTH); - } + char *outputConfigs[HEADS_MAX]; + for (int i = 0; i < HEADS_MAX; i++) outputConfigs[i] = (char *)malloc(MAX_NAME_LENGTH); struct wd_head_config *output; int description_index = 0; @@ -166,50 +183,29 @@ int store_config(struct wl_list *outputs) { struct wd_head *head = output->head; // for transform - char *trans_str = (char *)malloc(15 * sizeof(char)); + char *trans_str; switch (output->transform) { - case WL_OUTPUT_TRANSFORM_NORMAL: - strcpy(trans_str, "normal"); - break; - case WL_OUTPUT_TRANSFORM_90: - strcpy(trans_str, "90"); - break; - case WL_OUTPUT_TRANSFORM_180: - strcpy(trans_str, "180"); - break; - case WL_OUTPUT_TRANSFORM_270: - strcpy(trans_str, "270"); - break; - case WL_OUTPUT_TRANSFORM_FLIPPED_90: - strcpy(trans_str, "flipped-90"); - break; - case WL_OUTPUT_TRANSFORM_FLIPPED_180: - strcpy(trans_str, "flipped-180"); - break; - case WL_OUTPUT_TRANSFORM_FLIPPED_270: - strcpy(trans_str, "flipped-270"); - break; - default: - strcpy(trans_str, "normal"); - break; - } + case WL_OUTPUT_TRANSFORM_NORMAL : trans_str = "normal"; + case WL_OUTPUT_TRANSFORM_90 : trans_str = "90"; + case WL_OUTPUT_TRANSFORM_180 : trans_str = "180"; + case WL_OUTPUT_TRANSFORM_270 : trans_str = "270"; + case WL_OUTPUT_TRANSFORM_FLIPPED_90 : trans_str = "flipped-90"; + case WL_OUTPUT_TRANSFORM_FLIPPED_180: trans_str = "flipped-180"; + case WL_OUTPUT_TRANSFORM_FLIPPED_270: trans_str = "flipped-270"; + default : trans_str = "normal"; + }; - if (description_index < MAX_MONITORS_NUM) { + if (description_index < HEADS_MAX) { descriptions[description_index] = strdup(head->description); // write output config in given format - sprintf( - outputConfigs[description_index], - "output \"%s\" position %d,%d mode %dx%d@%.4f scale %.2f transform %s", - head->description, output->x, output->y, output->width, - output->height, output->refresh / 1.0e3, output->scale, trans_str); + sprintf(outputConfigs[description_index], "output \"%s\" position %d,%d mode %dx%d@%.4f scale %.2f transform %s", + head->description, output->x, output->y, output->width, output->height, output->refresh / 1.0e3, output->scale, + trans_str); description_index++; } else { - free(trans_str); - printf("Too many monitor! 10 is the"); + dprintf(2, "Too many monitor!\n\t%i is the maximum allowed number", HEADS_MAX); return 1; } - - free(trans_str); } int num_of_monitors = description_index; @@ -221,59 +217,57 @@ int store_config(struct wl_list *outputs) { // append new profile FILE *file = fopen(file_name, "a"); if (file == NULL) { - perror("File open failed."); - free(file_name); + dprintf(2, "%s:%i:%s(): Can't open %s : ", __FILE__, __LINE__, __func__, file_name); + perror(NULL); return 1; } fprintf(file, "\nprofile {\n"); - for (int i = 0; i= matched_profile.start && _line < matched_profile.end - 1) { - if(_i_output>=num_of_monitors){ + if (_i_output >= num_of_monitors) { perror("Null pointer"); fclose(tmp); fclose(file); return 1; } - fprintf(tmp," %s\n",outputConfigs[_i_output]); + fprintf(tmp, " %s\n", outputConfigs[_i_output]); free(outputConfigs[_i_output]); _i_output++; - } else{ - fprintf(tmp,"%s",_buffer); + } else { + fprintf(tmp, "%s", _buffer); } _line++; } fclose(file); fclose(tmp); - + remove(file_name); rename(tmp_file_name, file_name); - free(file_name); } return 0; -} \ No newline at end of file +} diff --git a/src/wdisplays.h b/src/wdisplays.h index 0e7a3c6..8dfef68 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -341,4 +341,19 @@ void wd_redraw_overlay(struct wd_output *output); */ void wd_destroy_overlay(struct wd_output *output); +/* + * Locate kanshi config + */ +char *wd_get_config_file_path(); + +/* + * Returns kanshi config path + */ +char *wd_get_kanshi_config(); + +/* + * Updates kanshi config + */ +int wd_store_config(struct wl_list *outputs); + #endif From 3d81cf51996c66f1195fd2a53ba3f92b1887c248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jason=20Andr=C3=A9=20Charles=20Gantner?= Date: Tue, 8 Jul 2025 02:10:16 +0200 Subject: [PATCH 05/38] Add licensing header(s) (#3) * Add partial licensing header * add licensing info where needed * Update SPDX header in store.c --------- Co-authored-by: TanShaochang <30321432+petertheprocess@users.noreply.github.com> --- src/store.c | 4 ++++ src/wdisplays.h | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/store.c b/src/store.c index f2c43c2..5d07272 100644 --- a/src/store.c +++ b/src/store.c @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024-2025 Shaochang Tan +// SPDX-FileCopyrightText: 2024-2025 Jason André Charles Gantner + #include "wdisplays.h" #include #include diff --git a/src/wdisplays.h b/src/wdisplays.h index 8dfef68..e20a609 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -341,6 +341,9 @@ 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 /* * Locate kanshi config */ @@ -355,5 +358,5 @@ char *wd_get_kanshi_config(); * Updates kanshi config */ int wd_store_config(struct wl_list *outputs); - +// SPDX-SnippetEnd #endif From 1fd8447fbb08d3e5927e7bb3d9d77d4799ab6c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jason=20Andr=C3=A9=20Charles=20Gantner?= Date: Tue, 8 Jul 2025 15:30:19 +0200 Subject: [PATCH 06/38] make kanshi config update an optional feature --- meson.build | 4 +++- meson.options | 3 +++ src/config.h.in | 3 +++ src/outputs.c | 4 +++- src/store.c | 20 ++++++++++++-------- src/wdisplays.h | 9 +++------ 6 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 meson.options diff --git a/meson.build b/meson.build index f9dec35..8081bd0 100644 --- a/meson.build +++ b/meson.build @@ -3,13 +3,15 @@ project('network.cycles.wdisplays', 'c', license: 'GPL-3.0-or-later', - version: '1.1.3' + version: '1.1.3', + meson_version:'>=1.1' ) 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 b/meson.options new file mode 100644 index 0000000..de78db1 --- /dev/null +++ b/meson.options @@ -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/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/outputs.c b/src/outputs.c index f4ac15c..b294451 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -54,11 +54,13 @@ static void config_handle_succeeded(void *data, struct wd_pending_config *pending = data; zwlr_output_configuration_v1_destroy(config); wd_ui_apply_done(pending->state, pending->outputs); - if (store_config(pending->outputs) == 0) +#ifdef WITH_KANSHI + if (wd_store_config(pending->outputs) == 0) { wd_ui_show_error(pending->state, "Change was applied successfully and config was saved."); } +#endif destroy_pending(pending); } diff --git a/src/store.c b/src/store.c index 5d07272..728b049 100644 --- a/src/store.c +++ b/src/store.c @@ -5,17 +5,12 @@ #include "wdisplays.h" #include #include -#include -#include #include #include -#include #include #define MAX_NAME_LENGTH 256 -struct wd_head_config; - struct profile_line { int start; int end; @@ -23,7 +18,7 @@ struct profile_line { typedef enum { Looking_for_profile, Looking_for_outputs, Found } parser_states; -char *wd_get_config_file_path() { +char *wd_get_kanshi_config_file_path() { char kanshiConfigPath[PATH_MAX]; char wdisplaysPath[PATH_MAX]; char defaultConfigDir[PATH_MAX]; @@ -75,8 +70,10 @@ char *wd_get_config_file_path() { } // reached end of file fclose(wdisplaysFile); } else { // can't open config file + #ifdef VERBOSE dprintf(2, "%s:%i:%s(): Can't open %s : ", __FILE__, __LINE__, __func__, wdisplaysPath); perror(NULL); + #endif } // look for WDISPLAYS_KANSHI_CONFIG @@ -107,7 +104,9 @@ struct profile_line match(char **descriptions, int num, const char *filename) { } // buffer to store each line char buffer[LINE_MAX]; +#ifdef VERBOSE char *profileName; +#endif int profileStartLine = 0; // mark the start line of matched profile int profileEndLine = 0; // mark the end line of matched profile @@ -123,12 +122,14 @@ struct profile_line match(char **descriptions, int num, const char *filename) { // check if "profile" keyword is in the line and remember its position char *pstart = strstr(buffer, "profile "); if (pstart != NULL) { + #ifdef VERBOSE pstart += 7; char *pend = strchr(pstart, '{'); // find the end of the profile name while (isspace(*pend)) pend--; size_t pnsize = pend - pstart; // use strndup to extract it without being size constrained profileName = strndup(pstart, pnsize); + #endif // record the start line of the profile profileStartLine = lineCount; ps = Looking_for_outputs; @@ -162,8 +163,11 @@ struct profile_line match(char **descriptions, int num, const char *filename) { } fclose(configFile); if (ps == Found) { + + #ifdef VERBOSE printf("Matched profile:%s\n", profileName); printf("Start line:%d\nEnd line:%d\n", profileStartLine, profileEndLine); + #endif matched_profile.start = profileStartLine; matched_profile.end = profileEndLine; } else dprintf(2, "%s:%i:%s(): Cannot find existing profile to match\n", __FILE__, __LINE__, __func__); @@ -171,7 +175,7 @@ struct profile_line match(char **descriptions, int num, const char *filename) { } int wd_store_config(struct wl_list *outputs) { - const char *file_name = wd_get_kanshi_config(); + const char *file_name = wd_get_kanshi_config_file_path(); char tmp_file_name[PATH_MAX]; sprintf(tmp_file_name, "%s.tmp", file_name); @@ -252,7 +256,7 @@ int wd_store_config(struct wl_list *outputs) { while (fgets(_buffer, sizeof(_buffer), file) != NULL) { if (_line >= matched_profile.start && _line < matched_profile.end - 1) { if (_i_output >= num_of_monitors) { - perror("Null pointer"); + dprintf(2, "%s:%i:%s(): too many outputs : %i", __FILE__, __LINE__, __func__, _i_output); fclose(tmp); fclose(file); return 1; diff --git a/src/wdisplays.h b/src/wdisplays.h index e20a609..9e2b76d 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -344,19 +344,16 @@ 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 /* * Locate kanshi config */ -char *wd_get_config_file_path(); - -/* - * Returns kanshi config path - */ -char *wd_get_kanshi_config(); +char *wd_get_kanshi_config_file_path(); /* * Updates kanshi config */ int wd_store_config(struct wl_list *outputs); +#endif // SPDX-SnippetEnd #endif From 2e7f3834e07ca43fa4e9bc265dcd6111837158f6 Mon Sep 17 00:00:00 2001 From: Stefan Zipproth Date: Fri, 27 Jun 2025 16:34:55 +0200 Subject: [PATCH 07/38] Fix kanshi compatibility by using output names instead of descriptions Kanshi fails to match profiles when output descriptions contain parentheses like 'Iiyama North America PL2294H2 1207823601758 (DP-3)'. According to kanshi(5) manual, valid output criteria are: - Output names (e.g. 'DP-1') - Manufacturer/Model/Serial without parentheses This changes store.c to use head->name (e.g. 'DP-3') instead of head->description, making kanshi profiles work correctly. Fixes 'no profile matched' error when kanshi tries to apply configurations. --- src/store.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/store.c b/src/store.c index 728b049..15d0021 100644 --- a/src/store.c +++ b/src/store.c @@ -204,10 +204,10 @@ int wd_store_config(struct wl_list *outputs) { }; if (description_index < HEADS_MAX) { - descriptions[description_index] = strdup(head->description); + descriptions[description_index] = strdup(head->name); // write output config in given format - sprintf(outputConfigs[description_index], "output \"%s\" position %d,%d mode %dx%d@%.4f scale %.2f transform %s", - head->description, output->x, output->y, output->width, output->height, output->refresh / 1.0e3, output->scale, + sprintf(outputConfigs[description_index], "output %s position %d,%d mode %dx%d@%.4f scale %.2f transform %s", + head->name, output->x, output->y, output->width, output->height, output->refresh / 1.0e3, output->scale, trans_str); description_index++; } else { From 692c5ff90ecc047f4892ba3be0e697f642124767 Mon Sep 17 00:00:00 2001 From: Stefan Zipproth Date: Fri, 27 Jun 2025 22:41:40 +0200 Subject: [PATCH 08/38] Fix profile matching for unquoted output syntax The match() function was still using the old quoted output syntax ("output \"%99[^\"]\"" pattern) which fails to match profiles written with the new unquoted syntax (output DP-3). This caused duplicate profiles to be created instead of updating existing ones. Now supports both quoted (legacy) and unquoted (current) output formats for backward compatibility. --- src/store.c | 46 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/store.c b/src/store.c index 15d0021..8ecc9b6 100644 --- a/src/store.c +++ b/src/store.c @@ -142,17 +142,43 @@ struct profile_line match(char **descriptions, int num, const char *filename) { profileEndLine = lineCount; if (profileMatchedNum == num) ps = Found; } else { - char *on_start = strstr(buffer, "output"); - on_start = strchr(on_start, '"'); - on_start++; - char *on_end = strchr(on_start, '"'); - char *outputName = strndup(on_start, on_end - on_start); + char outputName[MAX_NAME_LENGTH]; + char *trimmedBuffer = buffer; + while (isspace(*trimmedBuffer)) { + trimmedBuffer++; // skip leading spaces + } + char tempName[MAX_NAME_LENGTH]; + int matched_scan = 0; + + // Try quoted format first (legacy): output "Long Description (DP-3)" + if (sscanf(trimmedBuffer, "output \"%255[^\"]\"", tempName) == 1) { + // Extract output name from parentheses if present: (DP-3) -> DP-3 + char *paren_start = strrchr(tempName, '('); + char *paren_end = strrchr(tempName, ')'); + if (paren_start && paren_end && paren_end > paren_start) { + size_t len = paren_end - paren_start - 1; + strncpy(outputName, paren_start + 1, len); + outputName[len] = '\0'; + matched_scan = 1; + } + } else if (sscanf(trimmedBuffer, "output %99s", outputName) == 1) { + // Try unquoted format: output DP-3 + matched_scan = 1; + } + + if (matched_scan != 1) continue; // Skip unparseable lines + // check if the output name is in the descriptions - int i = 0; - while (descriptions[i] != NULL && strcmp(outputName, descriptions[i])) i++; - if (descriptions[i] != NULL) { - profileMatchedNum++; - } else { + bool matched = false; + for (int i = 0; descriptions[i] != NULL; i++) { + if (strcmp(outputName, descriptions[i]) == 0) { + matched = true; + profileMatchedNum++; + break; + } + } + + if (!matched) { // if any output is not matched, break profileMatchedNum = 0; ps = Looking_for_profile; From 2b6328c3a6e846622319ca15713d6e4a9d275317 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:17:42 +0100 Subject: [PATCH 09/38] Rename meson.options to meson_options.txt --- meson.build | 3 +-- meson.options => meson_options.txt | 0 2 files changed, 1 insertion(+), 2 deletions(-) rename meson.options => meson_options.txt (100%) diff --git a/meson.build b/meson.build index 8081bd0..913acea 100644 --- a/meson.build +++ b/meson.build @@ -3,8 +3,7 @@ project('network.cycles.wdisplays', 'c', license: 'GPL-3.0-or-later', - version: '1.1.3', - meson_version:'>=1.1' + version: '1.1.3' ) conf = configuration_data({ diff --git a/meson.options b/meson_options.txt similarity index 100% rename from meson.options rename to meson_options.txt From a7406b7ce0274efaf39878f452e915f691b2c415 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:02:11 +0100 Subject: [PATCH 10/38] Build store.c only with the kanshi feature --- src/meson.build | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/meson.build b/src/meson.build index 8527452..3bb36a3 100644 --- a/src/meson.build +++ b/src/meson.build @@ -11,18 +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', - 'store.c', - resources, - ], + sources, dependencies : [ m_dep, rt_dep, From ac6968b5256ce7a5d4e9c12a44e4e4a376193de2 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:09:19 +0100 Subject: [PATCH 11/38] Remove stale store_config declaration --- src/outputs.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/outputs.c b/src/outputs.c index b294451..8c83b13 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -28,8 +28,6 @@ #include "wlr-screencopy-unstable-v1-client-protocol.h" #include "wlr-layer-shell-unstable-v1-client-protocol.h" -extern int store_config(struct wl_list *outputs); - static void noop() { // This space is intentionally left blank } From a5946d02c9bd7e2ee11a34abe8ea450ab3bc9fe8 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:09:49 +0100 Subject: [PATCH 12/38] Update wlr-output-management protocol to version 2 --- .../wlr-output-management-unstable-v1.xml | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) 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. From 07aedb57308db954f1c7b33a2f411007a0af3169 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:29:02 +0100 Subject: [PATCH 13/38] Request make, model and serial from heads --- src/outputs.c | 26 +++++++++++++++++++++++++- src/wdisplays.h | 1 + 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/outputs.c b/src/outputs.c index 8c83b13..9c404b7 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -344,6 +344,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); } @@ -463,6 +466,24 @@ 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 head_handle_finished(void *data, struct zwlr_output_head_v1 *wlr_head) { struct wd_head *head = data; @@ -493,6 +514,9 @@ 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, @@ -541,7 +565,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) { diff --git a/src/wdisplays.h b/src/wdisplays.h index 9e2b76d..05801f9 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -105,6 +105,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; From 494563d48f13f6c6d3093097d15855750720e8b5 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:24:48 +0100 Subject: [PATCH 14/38] Write every transform to the kanshi config --- src/store.c | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/store.c b/src/store.c index 8ecc9b6..00d20f7 100644 --- a/src/store.c +++ b/src/store.c @@ -200,6 +200,27 @@ struct profile_line match(char **descriptions, int num, const char *filename) { return matched_profile; } +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"; + } +} + int wd_store_config(struct wl_list *outputs) { const char *file_name = wd_get_kanshi_config_file_path(); char tmp_file_name[PATH_MAX]; @@ -216,18 +237,7 @@ int wd_store_config(struct wl_list *outputs) { wl_list_for_each(output, outputs, link) { struct wd_head *head = output->head; - // for transform - char *trans_str; - switch (output->transform) { - case WL_OUTPUT_TRANSFORM_NORMAL : trans_str = "normal"; - case WL_OUTPUT_TRANSFORM_90 : trans_str = "90"; - case WL_OUTPUT_TRANSFORM_180 : trans_str = "180"; - case WL_OUTPUT_TRANSFORM_270 : trans_str = "270"; - case WL_OUTPUT_TRANSFORM_FLIPPED_90 : trans_str = "flipped-90"; - case WL_OUTPUT_TRANSFORM_FLIPPED_180: trans_str = "flipped-180"; - case WL_OUTPUT_TRANSFORM_FLIPPED_270: trans_str = "flipped-270"; - default : trans_str = "normal"; - }; + const char *trans_str = transform_name(output->transform); if (description_index < HEADS_MAX) { descriptions[description_index] = strdup(head->name); From d428057171c1d32433c28cec8f4f02a26414df1d Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:31:21 +0100 Subject: [PATCH 15/38] Write 'disable' for outputs that are turned off --- src/store.c | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/store.c b/src/store.c index 00d20f7..bfea8f7 100644 --- a/src/store.c +++ b/src/store.c @@ -241,10 +241,13 @@ int wd_store_config(struct wl_list *outputs) { if (description_index < HEADS_MAX) { descriptions[description_index] = strdup(head->name); - // write output config in given format - sprintf(outputConfigs[description_index], "output %s position %d,%d mode %dx%d@%.4f scale %.2f transform %s", - head->name, output->x, output->y, output->width, output->height, output->refresh / 1.0e3, output->scale, - trans_str); + if (!output->enabled) { + sprintf(outputConfigs[description_index], "output %s disable", head->name); + } else { + sprintf(outputConfigs[description_index], "output %s enable position %d,%d mode %dx%d@%.4f scale %.2f transform %s", + head->name, output->x, output->y, output->width, output->height, output->refresh / 1.0e3, output->scale, + trans_str); + } description_index++; } else { dprintf(2, "Too many monitor!\n\t%i is the maximum allowed number", HEADS_MAX); From dd2a2155f4852d3326dc360fe1203b4721c9ad14 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:52:29 +0100 Subject: [PATCH 16/38] Write kanshi numbers in the C locale --- src/store.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/store.c b/src/store.c index bfea8f7..d66cfbc 100644 --- a/src/store.c +++ b/src/store.c @@ -244,9 +244,12 @@ int wd_store_config(struct wl_list *outputs) { if (!output->enabled) { sprintf(outputConfigs[description_index], "output %s disable", head->name); } else { - sprintf(outputConfigs[description_index], "output %s enable position %d,%d mode %dx%d@%.4f scale %.2f transform %s", - head->name, output->x, output->y, output->width, output->height, output->refresh / 1.0e3, output->scale, - trans_str); + char refresh[G_ASCII_DTOSTR_BUF_SIZE]; + char scale[G_ASCII_DTOSTR_BUF_SIZE]; + g_ascii_formatd(refresh, sizeof(refresh), "%.3f", output->refresh / 1.0e3); + g_ascii_dtostr(scale, sizeof(scale), output->scale); + sprintf(outputConfigs[description_index], "output %s enable position %d,%d mode %dx%d@%sHz scale %s transform %s", + head->name, output->x, output->y, output->width, output->height, refresh, scale, trans_str); } description_index++; } else { From b099e8aeb2e71556acbb9b2e4b5804a04e1e0ab4 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:02:50 +0100 Subject: [PATCH 17/38] Look up the kanshi config path with GLib --- src/store.c | 89 ++++++++++++------------------------------------- src/wdisplays.h | 5 --- 2 files changed, 21 insertions(+), 73 deletions(-) diff --git a/src/store.c b/src/store.c index d66cfbc..ddea446 100644 --- a/src/store.c +++ b/src/store.c @@ -18,77 +18,30 @@ struct profile_line { typedef enum { Looking_for_profile, Looking_for_outputs, Found } parser_states; -char *wd_get_kanshi_config_file_path() { - char kanshiConfigPath[PATH_MAX]; - char wdisplaysPath[PATH_MAX]; - char defaultConfigDir[PATH_MAX]; - // if $XDG_CONFIG_HOME is set, use it - { - char *configDir = getenv("XDG_CONFIG_HOME"); - if (configDir == NULL) { // fallback to $HOME - configDir = getenv("HOME"); - if (configDir == NULL) { - dprintf(2, "%s:%i:%s(): Cannot find $XDG_CONFIG_HOME nor $HOME directories", __FILE__, __LINE__, __func__); - return NULL; - } else { // configdir is $HOME/config - snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s/.config", configDir); +static char *get_config_path(void) { + const char *env_path = g_getenv("WDISPLAYS_KANSHI_CONFIG"); + if (env_path != NULL) { + return g_strdup(env_path); + } + + const char *config_dir = g_get_user_config_dir(); + g_autofree char *wdisplays_path = g_build_filename(config_dir, "wdisplays.conf", NULL); + g_autofree char *contents = NULL; + g_autofree char *store_path = NULL; + if (g_file_get_contents(wdisplays_path, &contents, NULL, NULL)) { + g_auto(GStrv) lines = g_strsplit(contents, "\n", -1); + for (char **line = lines; *line != NULL; line++) { + char *value = strchr(*line, '='); + if (strstr(*line, "store_path") != NULL && value != NULL) { + g_free(store_path); + store_path = g_strdup(g_strstrip(value + 1)); } - } else { // configDir is $XDG_CONFIG_HOME - snprintf(defaultConfigDir, sizeof(defaultConfigDir), "%s", configDir); } } - - // set default kanshi config path - snprintf(kanshiConfigPath, sizeof(kanshiConfigPath), "%s/kanshi/config", defaultConfigDir); - - // look for store_path in wdisplays.conf - snprintf(wdisplaysPath, sizeof(wdisplaysPath), "%s/wdisplays.conf", defaultConfigDir); - - FILE *wdisplaysFile = fopen(wdisplaysPath, "r"); - if (wdisplaysFile != NULL) { - char line[LINE_MAX]; // LINE_MAX is a platform-dependendant macro - - // try to match "store_path" term - while (fgets(line, sizeof(line), wdisplaysFile) != NULL) { - if (strstr(line, "store_path") != NULL) { - // if found, extract path - char *pathStart = strchr(line, '='); - if (pathStart != NULL) { - pathStart++; // skip '=' - while (isspace(*pathStart)) pathStart++; // skip spaces between '=' and the start of the path - char *pathEnd = strchr(pathStart, '\n'); - size_t pathLen; - if (pathEnd != NULL) pathLen = pathEnd - pathStart; - else // store_path= is the last line and there's no newline at the end of the file - pathLen = strnlen(pathStart, PATH_MAX); - // save path - strncpy(kanshiConfigPath, pathStart, pathLen); - } else - ; // store_path was not followed by an equal sign on this line - } else - ; // this line does not contain store_path - } // reached end of file - fclose(wdisplaysFile); - } else { // can't open config file - #ifdef VERBOSE - dprintf(2, "%s:%i:%s(): Can't open %s : ", __FILE__, __LINE__, __func__, wdisplaysPath); - perror(NULL); - #endif + if (store_path != NULL && store_path[0] != '\0') { + return g_steal_pointer(&store_path); } - - // look for WDISPLAYS_KANSHI_CONFIG - { - char *envKanshiConf = getenv("WDISPLAYS_KANSHI_CONFIG"); - if (envKanshiConf != NULL) strncpy(kanshiConfigPath, envKanshiConf, sizeof(kanshiConfigPath)); - else - ; - } - char *finalPath = strndup(kanshiConfigPath, PATH_MAX); - if (finalPath == NULL) { - dprintf(2, "%s:%i:%s(): ", __FILE__, __LINE__, __func__); - perror("Failed to allocate memory for kanshi config path"); - } - return finalPath; + return g_build_filename(config_dir, "kanshi", "config", NULL); } struct profile_line match(char **descriptions, int num, const char *filename) { @@ -222,7 +175,7 @@ static const char *transform_name(enum wl_output_transform transform) { } int wd_store_config(struct wl_list *outputs) { - const char *file_name = wd_get_kanshi_config_file_path(); + g_autofree char *file_name = get_config_path(); char tmp_file_name[PATH_MAX]; sprintf(tmp_file_name, "%s.tmp", file_name); diff --git a/src/wdisplays.h b/src/wdisplays.h index 05801f9..ce0f6e2 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -346,11 +346,6 @@ void wd_destroy_overlay(struct wd_output *output); // SPDX-License-Identifier: MIT // SPDX-SnippetCopyrightText: 2024-2025 Jason André Charles Gantner #ifdef WITH_KANSHI -/* - * Locate kanshi config - */ -char *wd_get_kanshi_config_file_path(); - /* * Updates kanshi config */ From f20965d6ce54cde745c679ebfe9ec4cc3984e132 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:55:52 +0100 Subject: [PATCH 18/38] Parse the kanshi config the way kanshi does --- src/store.c | 626 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 420 insertions(+), 206 deletions(-) diff --git a/src/store.c b/src/store.c index ddea446..6042cd4 100644 --- a/src/store.c +++ b/src/store.c @@ -2,21 +2,24 @@ // SPDX-FileCopyrightText: 2024-2025 Shaochang Tan // SPDX-FileCopyrightText: 2024-2025 Jason André Charles Gantner -#include "wdisplays.h" -#include -#include -#include +#include #include -#include -#define MAX_NAME_LENGTH 256 +#include "wdisplays.h" -struct profile_line { - int start; - int end; +struct kanshi_directive { + char *name; + GPtrArray *params; + GPtrArray *children; + size_t start, name_start, end; + size_t criteria_start, criteria_end; }; -typedef enum { Looking_for_profile, Looking_for_outputs, Found } parser_states; +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"); @@ -44,113 +47,283 @@ static char *get_config_path(void) { return g_build_filename(config_dir, "kanshi", "config", NULL); } -struct profile_line match(char **descriptions, int num, const char *filename) { - struct profile_line matched_profile; - matched_profile.start = -1; - matched_profile.end = -1; - // -1 means not found - FILE *configFile = fopen(filename, "r"); - if (configFile == NULL) { - dprintf(2, "%s:%i:%s(): Can't open %s : ", __FILE__, __LINE__, __func__, filename); - perror(NULL); - return matched_profile; +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); } - // buffer to store each line - char buffer[LINE_MAX]; -#ifdef VERBOSE - char *profileName; -#endif - int profileStartLine = 0; // mark the start line of matched profile - int profileEndLine = 0; // mark the end line of matched profile + g_free(dir); +} - int lineCount = 0; // current line number - uint32_t profileMatchedNum = 0; // current number of matched outputs - parser_states ps = Looking_for_profile; // current state of the parser - while (ps != Found && fgets(buffer, sizeof(buffer), configFile) != NULL) { - lineCount++; - switch (ps) { - case Found: break; // unreachable code +static char peek(struct kanshi_parser *parser) { + return parser->text[parser->pos]; +} - case Looking_for_profile:; - // check if "profile" keyword is in the line and remember its position - char *pstart = strstr(buffer, "profile "); - if (pstart != NULL) { - #ifdef VERBOSE - pstart += 7; - char *pend = strchr(pstart, '{'); // find the end of the profile name - while (isspace(*pend)) pend--; - size_t pnsize = pend - pstart; - // use strndup to extract it without being size constrained - profileName = strndup(pstart, pnsize); - #endif - // record the start line of the profile - profileStartLine = lineCount; - ps = Looking_for_outputs; - } +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; - - case Looking_for_outputs: - // check if the profile ends - if (buffer[0] == '}') { - profileEndLine = lineCount; - if (profileMatchedNum == num) ps = Found; - } else { - char outputName[MAX_NAME_LENGTH]; - char *trimmedBuffer = buffer; - while (isspace(*trimmedBuffer)) { - trimmedBuffer++; // skip leading spaces - } - char tempName[MAX_NAME_LENGTH]; - int matched_scan = 0; - - // Try quoted format first (legacy): output "Long Description (DP-3)" - if (sscanf(trimmedBuffer, "output \"%255[^\"]\"", tempName) == 1) { - // Extract output name from parentheses if present: (DP-3) -> DP-3 - char *paren_start = strrchr(tempName, '('); - char *paren_end = strrchr(tempName, ')'); - if (paren_start && paren_end && paren_end > paren_start) { - size_t len = paren_end - paren_start - 1; - strncpy(outputName, paren_start + 1, len); - outputName[len] = '\0'; - matched_scan = 1; - } - } else if (sscanf(trimmedBuffer, "output %99s", outputName) == 1) { - // Try unquoted format: output DP-3 - matched_scan = 1; - } - - if (matched_scan != 1) continue; // Skip unparseable lines - - // check if the output name is in the descriptions - bool matched = false; - for (int i = 0; descriptions[i] != NULL; i++) { - if (strcmp(outputName, descriptions[i]) == 0) { - matched = true; - profileMatchedNum++; - break; - } - } - - if (!matched) { - // if any output is not matched, break - profileMatchedNum = 0; - ps = Looking_for_profile; - } - } + } + 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); } } - fclose(configFile); - if (ps == Found) { + g_ptr_array_unref(dirs); + return NULL; +} - #ifdef VERBOSE - printf("Matched profile:%s\n", profileName); - printf("Start line:%d\nEnd line:%d\n", profileStartLine, profileEndLine); - #endif - matched_profile.start = profileStartLine; - matched_profile.end = profileEndLine; - } else dprintf(2, "%s:%i:%s(): Cannot find existing profile to match\n", __FILE__, __LINE__, __func__); - return matched_profile; +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(GPtrArray *config, const char *criteria) { + if (criteria[0] != '$') { + return criteria; + } + for (guint i = 0; i < config->len; i++) { + struct kanshi_directive *dir = g_ptr_array_index(config, i); + const char *alias = is_output(dir) ? find_param(dir, "alias") : NULL; + if (alias != NULL && strcmp(alias, criteria) == 0) { + return g_ptr_array_index(dir->params, 0); + } + } + return NULL; +} + +static bool match_profile(GPtrArray *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 const char *transform_name(enum wl_output_transform transform) { @@ -174,103 +347,144 @@ static const char *transform_name(enum wl_output_transform transform) { } } -int wd_store_config(struct wl_list *outputs) { - g_autofree char *file_name = get_config_path(); - char tmp_file_name[PATH_MAX]; - sprintf(tmp_file_name, "%s.tmp", file_name); - - char *descriptions[HEADS_MAX]; - for (int i = 0; i < HEADS_MAX; i++) descriptions[i] = NULL; - - char *outputConfigs[HEADS_MAX]; - for (int i = 0; i < HEADS_MAX; i++) outputConfigs[i] = (char *)malloc(MAX_NAME_LENGTH); - - struct wd_head_config *output; - int description_index = 0; - wl_list_for_each(output, outputs, link) { - struct wd_head *head = output->head; - - const char *trans_str = transform_name(output->transform); - - if (description_index < HEADS_MAX) { - descriptions[description_index] = strdup(head->name); - if (!output->enabled) { - sprintf(outputConfigs[description_index], "output %s disable", head->name); - } else { - char refresh[G_ASCII_DTOSTR_BUF_SIZE]; - char scale[G_ASCII_DTOSTR_BUF_SIZE]; - g_ascii_formatd(refresh, sizeof(refresh), "%.3f", output->refresh / 1.0e3); - g_ascii_dtostr(scale, sizeof(scale), output->scale); - sprintf(outputConfigs[description_index], "output %s enable position %d,%d mode %dx%d@%sHz scale %s transform %s", - head->name, output->x, output->y, output->width, output->height, refresh, scale, trans_str); - } - description_index++; - } else { - dprintf(2, "Too many monitor!\n\t%i is the maximum allowed number", HEADS_MAX); - return 1; +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; } } - - int num_of_monitors = description_index; - - struct profile_line matched_profile; - matched_profile = match(descriptions, num_of_monitors, file_name); - - if (matched_profile.start == -1) { - // append new profile - FILE *file = fopen(file_name, "a"); - if (file == NULL) { - dprintf(2, "%s:%i:%s(): Can't open %s : ", __FILE__, __LINE__, __func__, file_name); - perror(NULL); - return 1; - } - fprintf(file, "\nprofile {\n"); - for (int i = 0; i < num_of_monitors; i++) { - fprintf(file, " %s\n", outputConfigs[i]); - free(outputConfigs[i]); - } - fprintf(file, "}"); - fclose(file); - } else if (matched_profile.start < matched_profile.end) { - // rewrite corresponding lines - FILE *file = fopen(file_name, "r"); - if (file == NULL) { - perror("File open failed."); - return 1; - } - FILE *tmp = fopen(tmp_file_name, "w"); - if (tmp == NULL) { - dprintf(2, "%s:%i:%s(): Can't create %s : ", __FILE__, __LINE__, __func__, tmp_file_name); - perror(NULL); - fclose(file); - return 1; - } - char _buffer[LINE_MAX]; - int _line = 0; - int _i_output = 0; - while (fgets(_buffer, sizeof(_buffer), file) != NULL) { - if (_line >= matched_profile.start && _line < matched_profile.end - 1) { - if (_i_output >= num_of_monitors) { - dprintf(2, "%s:%i:%s(): too many outputs : %i", __FILE__, __LINE__, __func__, _i_output); - fclose(tmp); - fclose(file); - return 1; - } - fprintf(tmp, " %s\n", outputConfigs[_i_output]); - free(outputConfigs[_i_output]); - - _i_output++; - } else { - fprintf(tmp, "%s", _buffer); - } - _line++; - } - fclose(file); - fclose(tmp); - - remove(file_name); - rename(tmp_file_name, file_name); - } - - return 0; + return true; +} + +static void append_settings(GString *str, struct wd_head_config *output, + const char *adaptive_sync) { + if (!output->enabled) { + g_string_append(str, " disable\n"); + return; + } + 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]; + g_ascii_dtostr(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 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) { + 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_word(str, heads[i]->head->name); + append_settings(str, heads[i], NULL); + } + g_string_append(str, "}\n"); +} + +static char *update_config(const char *text, struct wl_list *outputs, + 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; + } + + struct kanshi_parser parser = { .text = text, .line = 1 }; + g_autoptr(GPtrArray) config = parse_block(&parser, false, error); + if (config == NULL) { + return NULL; + } + + GString *str = g_string_new(NULL); + struct kanshi_directive *matches[HEADS_MAX]; + for (guint i = 0; i < config->len; i++) { + struct kanshi_directive *dir = g_ptr_array_index(config, i); + if (strcmp(dir->name, "profile") == 0 && dir->children != NULL + && match_profile(config, dir, heads, num_heads, matches)) { + rewrite_profile(str, text, dir, heads, num_heads, matches); + return g_string_free(str, FALSE); + } + } + g_string_append(str, text); + append_profile(str, heads, num_heads); + return g_string_free(str, FALSE); +} + +int wd_store_config(struct wl_list *outputs) { + g_autofree char *path = get_config_path(); + g_autofree char *contents = NULL; + g_autofree char *updated = NULL; + g_autoptr(GError) error = NULL; + if (!g_file_get_contents(path, &contents, NULL, &error)) { + if (!g_error_matches(error, G_FILE_ERROR, G_FILE_ERROR_NOENT)) { + goto err; + } + g_clear_error(&error); + contents = g_strdup(""); + } + updated = update_config(contents, outputs, &error); + if (updated == NULL || !g_file_set_contents(path, updated, -1, &error)) { + goto err; + } + return 0; + +err: + fprintf(stderr, "Could not save the kanshi config %s: %s\n", path, error->message); + return 1; } From 0e9f4449479bdfc251601a490987bf214ec52207 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:56:10 +0100 Subject: [PATCH 19/38] Create a missing kanshi config directory --- src/store.c | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/store.c b/src/store.c index 6042cd4..a63ae82 100644 --- a/src/store.c +++ b/src/store.c @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2024-2025 Shaochang Tan // SPDX-FileCopyrightText: 2024-2025 Jason André Charles Gantner +#include #include #include @@ -466,6 +467,18 @@ static char *update_config(const char *text, struct wl_list *outputs, return g_string_free(str, FALSE); } +static bool write_config(const char *path, const char *contents, + GError **error) { + 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); +} + int wd_store_config(struct wl_list *outputs) { g_autofree char *path = get_config_path(); g_autofree char *contents = NULL; @@ -479,7 +492,7 @@ int wd_store_config(struct wl_list *outputs) { contents = g_strdup(""); } updated = update_config(contents, outputs, &error); - if (updated == NULL || !g_file_set_contents(path, updated, -1, &error)) { + if (updated == NULL || !write_config(path, updated, &error)) { goto err; } return 0; From 6bed905159542c4d31da0d76765b71a63720f15f Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:46:49 +0100 Subject: [PATCH 20/38] Keep a symlinked kanshi config a symlink --- src/store.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/store.c b/src/store.c index a63ae82..18db58e 100644 --- a/src/store.c +++ b/src/store.c @@ -4,6 +4,7 @@ #include #include +#include #include #include "wdisplays.h" @@ -469,6 +470,10 @@ static char *update_config(const char *text, struct wl_list *outputs, static bool write_config(const char *path, const char *contents, GError **error) { + g_autofree char *real_path = realpath(path, NULL); + if (real_path != NULL) { + path = real_path; + } g_autofree char *dir = g_path_get_dirname(path); if (g_mkdir_with_parents(dir, 0755) != 0) { int err = errno; From 9cf3651140a570834a15d0baf69f5438e039a93c Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:52:28 +0100 Subject: [PATCH 21/38] Name new kanshi outputs by their EDID identity --- src/store.c | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/store.c b/src/store.c index 18db58e..c96aaaa 100644 --- a/src/store.c +++ b/src/store.c @@ -328,6 +328,35 @@ static void append_word(GString *str, const char *word) { 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: @@ -424,7 +453,7 @@ static void append_profile(GString *str, struct wd_head_config **heads, g_string_append(str, "profile {\n"); for (int i = 0; i < num_heads; i++) { g_string_append(str, "\toutput "); - append_word(str, heads[i]->head->name); + append_criteria(str, heads, num_heads, heads[i]->head); append_settings(str, heads[i], NULL); } g_string_append(str, "}\n"); From e0699ff04dea766f915df19027f1da30eef78e61 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:17:07 +0100 Subject: [PATCH 22/38] Report kanshi config errors in the info bar only --- src/outputs.c | 6 +----- src/store.c | 11 ++++++----- src/wdisplays.h | 2 +- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/outputs.c b/src/outputs.c index 9c404b7..b393feb 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -53,11 +53,7 @@ static void config_handle_succeeded(void *data, zwlr_output_configuration_v1_destroy(config); wd_ui_apply_done(pending->state, pending->outputs); #ifdef WITH_KANSHI - if (wd_store_config(pending->outputs) == 0) - { - wd_ui_show_error(pending->state, - "Change was applied successfully and config was saved."); - } + wd_store_config(pending->state, pending->outputs); #endif destroy_pending(pending); } diff --git a/src/store.c b/src/store.c index c96aaaa..d5ae05a 100644 --- a/src/store.c +++ b/src/store.c @@ -513,7 +513,7 @@ static bool write_config(const char *path, const char *contents, return g_file_set_contents(path, contents, -1, error); } -int wd_store_config(struct wl_list *outputs) { +void wd_store_config(struct wd_state *state, struct wl_list *outputs) { g_autofree char *path = get_config_path(); g_autofree char *contents = NULL; g_autofree char *updated = NULL; @@ -529,9 +529,10 @@ int wd_store_config(struct wl_list *outputs) { if (updated == NULL || !write_config(path, updated, &error)) { goto err; } - return 0; + return; -err: - fprintf(stderr, "Could not save the kanshi config %s: %s\n", path, error->message); - return 1; +err:; + g_autofree char *message = g_strdup_printf( + "Could not save the kanshi config %s: %s", path, error->message); + wd_ui_show_error(state, message); } diff --git a/src/wdisplays.h b/src/wdisplays.h index ce0f6e2..de77538 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -349,7 +349,7 @@ void wd_destroy_overlay(struct wd_output *output); /* * Updates kanshi config */ -int wd_store_config(struct wl_list *outputs); +void wd_store_config(struct wd_state *state, struct wl_list *outputs); #endif // SPDX-SnippetEnd #endif From 80fa88173bbd33ecbe23da4f475622cc40659cf4 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:11:26 +0100 Subject: [PATCH 23/38] Add a menu toggle for saving to the kanshi config --- resources/wdisplays.gschema.xml | 3 +++ src/main.c | 24 ++++++++++++++++++++++++ src/outputs.c | 4 +++- src/wdisplays.h | 1 + 4 files changed, 31 insertions(+), 1 deletion(-) 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/main.c b/src/main.c index 176731d..eaf8026 100644 --- a/src/main.c +++ b/src/main.c @@ -904,6 +904,17 @@ 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); + g_simple_action_set_state(action, param); +} +#endif + static void window_state_changed(GtkWidget *window, GdkEventWindowState *event, gpointer data) { struct wd_state *state = data; @@ -1037,6 +1048,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 } action = g_simple_action_new_stateful("auto-apply", NULL, @@ -1054,10 +1068,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/outputs.c b/src/outputs.c index b393feb..f41ee01 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -53,7 +53,9 @@ static void config_handle_succeeded(void *data, zwlr_output_configuration_v1_destroy(config); wd_ui_apply_done(pending->state, pending->outputs); #ifdef WITH_KANSHI - wd_store_config(pending->state, pending->outputs); + if (pending->state->save_kanshi_config) { + wd_store_config(pending->state, pending->outputs); + } #endif destroy_pending(pending); } diff --git a/src/wdisplays.h b/src/wdisplays.h index de77538..83690a2 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -190,6 +190,7 @@ struct wd_state { bool autoapply; bool capture; bool show_overlay; + bool save_kanshi_config; double zoom; unsigned int apply_idle; From 85c801526b4ccfcdfdcd5a0c6f857d292176b6a9 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:49:07 +0100 Subject: [PATCH 24/38] Reload kanshi after saving its config --- src/store.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/store.c b/src/store.c index d5ae05a..27008d5 100644 --- a/src/store.c +++ b/src/store.c @@ -513,6 +513,18 @@ static bool write_config(const char *path, const char *contents, 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_autofree char *contents = NULL; @@ -529,6 +541,7 @@ void wd_store_config(struct wd_state *state, struct wl_list *outputs) { if (updated == NULL || !write_config(path, updated, &error)) { goto err; } + reload_kanshi(); return; err:; From e37e313b80038c6979bda298328c030e61b8f5d8 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:06:53 +0100 Subject: [PATCH 25/38] Treat an empty WDISPLAYS_KANSHI_CONFIG as unset --- src/store.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store.c b/src/store.c index 27008d5..5a563ed 100644 --- a/src/store.c +++ b/src/store.c @@ -25,7 +25,7 @@ struct kanshi_parser { static char *get_config_path(void) { const char *env_path = g_getenv("WDISPLAYS_KANSHI_CONFIG"); - if (env_path != NULL) { + if (env_path != NULL && env_path[0] != '\0') { return g_strdup(env_path); } From 82c33052b004998424432a07fcbbcd52102de731 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:31:24 +0100 Subject: [PATCH 26/38] Write through dangling kanshi config symlinks --- src/store.c | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/store.c b/src/store.c index 5a563ed..93bfc63 100644 --- a/src/store.c +++ b/src/store.c @@ -4,7 +4,6 @@ #include #include -#include #include #include "wdisplays.h" @@ -497,12 +496,27 @@ static char *update_config(const char *text, struct wl_list *outputs, return g_string_free(str, FALSE); } -static bool write_config(const char *path, const char *contents, - GError **error) { - g_autofree char *real_path = realpath(path, NULL); - if (real_path != NULL) { - path = real_path; +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; From 0d806aa38ae81b790a45e711cf61fa0640fffde9 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:52:42 +0100 Subject: [PATCH 27/38] Keep adaptive_sync on disabled kanshi outputs --- src/store.c | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/store.c b/src/store.c index 93bfc63..20ed86e 100644 --- a/src/store.c +++ b/src/store.c @@ -391,23 +391,23 @@ static bool is_custom_mode(struct wd_head_config *output) { static void append_settings(GString *str, struct wd_head_config *output, const char *adaptive_sync) { if (!output->enabled) { - g_string_append(str, " disable\n"); - return; + 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]; + g_ascii_dtostr(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)); } - 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]; - g_ascii_dtostr(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); From 879352f129939961ff1aab289b5eeadeb73c784b Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:41:12 +0100 Subject: [PATCH 28/38] Keep comments inside block-form kanshi outputs --- src/store.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/store.c b/src/store.c index 20ed86e..5c57c21 100644 --- a/src/store.c +++ b/src/store.c @@ -415,6 +415,19 @@ static void append_settings(GString *str, struct wd_head_config *output, 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) { @@ -429,6 +442,7 @@ static void rewrite_profile(GString *str, const char *text, } 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 "); From 1522139259da00887a48fda24cf1207290b47614 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:06:44 +0100 Subject: [PATCH 29/38] Follow includes when matching kanshi profiles --- src/store.c | 156 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 127 insertions(+), 29 deletions(-) diff --git a/src/store.c b/src/store.c index 5c57c21..1a51093 100644 --- a/src/store.c +++ b/src/store.c @@ -5,15 +5,31 @@ #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 { @@ -258,13 +274,14 @@ static bool criteria_match(const char *criteria, struct wd_head *head) { || fnmatch(criteria, identifier, 0) == 0; } -static const char *resolve_alias(GPtrArray *config, const char *criteria) { +static const char *resolve_alias(struct kanshi_config *config, + const char *criteria) { if (criteria[0] != '$') { return criteria; } - for (guint i = 0; i < config->len; i++) { - struct kanshi_directive *dir = g_ptr_array_index(config, i); - const char *alias = is_output(dir) ? find_param(dir, "alias") : NULL; + 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); } @@ -272,7 +289,8 @@ static const char *resolve_alias(GPtrArray *config, const char *criteria) { return NULL; } -static bool match_profile(GPtrArray *config, struct kanshi_directive *profile, +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(); @@ -472,8 +490,93 @@ static void append_profile(GString *str, struct wd_head_config **heads, g_string_append(str, "}\n"); } -static char *update_config(const char *text, struct wl_list *outputs, - GError **error) { +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; @@ -489,23 +592,18 @@ static char *update_config(const char *text, struct wl_list *outputs, return NULL; } - struct kanshi_parser parser = { .text = text, .line = 1 }; - g_autoptr(GPtrArray) config = parse_block(&parser, false, error); - if (config == NULL) { - return NULL; - } - GString *str = g_string_new(NULL); struct kanshi_directive *matches[HEADS_MAX]; - for (guint i = 0; i < config->len; i++) { - struct kanshi_directive *dir = g_ptr_array_index(config, i); - if (strcmp(dir->name, "profile") == 0 && dir->children != NULL - && match_profile(config, dir, heads, num_heads, matches)) { - rewrite_profile(str, text, dir, heads, num_heads, matches); + 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); } } - g_string_append(str, text); + *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); } @@ -555,18 +653,18 @@ static void reload_kanshi(void) { void wd_store_config(struct wd_state *state, struct wl_list *outputs) { g_autofree char *path = get_config_path(); - g_autofree char *contents = NULL; + 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 (!g_file_get_contents(path, &contents, NULL, &error)) { - if (!g_error_matches(error, G_FILE_ERROR, G_FILE_ERROR_NOENT)) { - goto err; - } - g_clear_error(&error); - contents = g_strdup(""); + if (!load_file(&config, path, false, 0, &error)) { + goto err; } - updated = update_config(contents, outputs, &error); - if (updated == NULL || !write_config(path, updated, &error)) { + updated = update_config(&config, outputs, &file, &error); + if (updated == NULL || !write_config(file->path, updated, &error)) { goto err; } reload_kanshi(); @@ -574,6 +672,6 @@ void wd_store_config(struct wd_state *state, struct wl_list *outputs) { err:; g_autofree char *message = g_strdup_printf( - "Could not save the kanshi config %s: %s", path, error->message); + "Could not save the kanshi config: %s", error->message); wd_ui_show_error(state, message); } From 3a2a1878d5eafefbdd4f0ff9470d792acc09ff92 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:51:10 +0100 Subject: [PATCH 30/38] Drop the store_path setting from wdisplays.conf --- src/store.c | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/src/store.c b/src/store.c index 1a51093..4e6a166 100644 --- a/src/store.c +++ b/src/store.c @@ -44,24 +44,7 @@ static char *get_config_path(void) { return g_strdup(env_path); } - const char *config_dir = g_get_user_config_dir(); - g_autofree char *wdisplays_path = g_build_filename(config_dir, "wdisplays.conf", NULL); - g_autofree char *contents = NULL; - g_autofree char *store_path = NULL; - if (g_file_get_contents(wdisplays_path, &contents, NULL, NULL)) { - g_auto(GStrv) lines = g_strsplit(contents, "\n", -1); - for (char **line = lines; *line != NULL; line++) { - char *value = strchr(*line, '='); - if (strstr(*line, "store_path") != NULL && value != NULL) { - g_free(store_path); - store_path = g_strdup(g_strstrip(value + 1)); - } - } - } - if (store_path != NULL && store_path[0] != '\0') { - return g_steal_pointer(&store_path); - } - return g_build_filename(config_dir, "kanshi", "config", NULL); + return g_build_filename(g_get_user_config_dir(), "kanshi", "config", NULL); } static void kanshi_directive_free(gpointer data) { From be98542f4e559714dfcfc14df764b8e05ca908f4 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:58:17 +0100 Subject: [PATCH 31/38] Save to the kanshi config only on a manual apply --- src/main.c | 8 +++++--- src/outputs.c | 4 +++- src/wdisplays.h | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main.c b/src/main.c index eaf8026..083a28f 100644 --- a/src/main.c +++ b/src/main.c @@ -50,10 +50,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"); @@ -300,7 +302,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"; } @@ -842,7 +844,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) { diff --git a/src/outputs.c b/src/outputs.c index f41ee01..6cec53a 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -35,6 +35,7 @@ static void noop() { struct wd_pending_config { struct wd_state *state; struct wl_list *outputs; + bool manual; }; static void destroy_pending(struct wd_pending_config *pending) { @@ -53,7 +54,7 @@ static void config_handle_succeeded(void *data, zwlr_output_configuration_v1_destroy(config); wd_ui_apply_done(pending->state, pending->outputs); #ifdef WITH_KANSHI - if (pending->state->save_kanshi_config) { + if (pending->manual && pending->state->save_kanshi_config) { wd_store_config(pending->state, pending->outputs); } #endif @@ -95,6 +96,7 @@ 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; zwlr_output_configuration_v1_add_listener(config, &config_listener, pending); diff --git a/src/wdisplays.h b/src/wdisplays.h index 83690a2..dbdb03d 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -187,6 +187,7 @@ struct wd_state { uint32_t serial; bool apply_pending; + bool apply_manual; bool autoapply; bool capture; bool show_overlay; From 1882fef7e778ac4e38b92fc2fcd229c947642edb Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:54:53 +0100 Subject: [PATCH 32/38] Save to the kanshi config when auto-apply ends --- src/main.c | 13 +++++++++++++ src/outputs.c | 7 +++++-- src/store.c | 31 +++++++++++++++++++++++++++++++ src/wdisplays.h | 7 +++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index 083a28f..86052cb 100644 --- a/src/main.c +++ b/src/main.c @@ -405,6 +405,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); @@ -421,6 +425,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_store_config_if_dirty(state); +#endif wd_state_destroy(state); } @@ -866,6 +873,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_store_config_if_dirty(state); + } +#endif } static gboolean redraw_canvas(GtkWidget *widget, GdkFrameClock *frame_clock, gpointer data) { @@ -913,6 +925,7 @@ static void save_kanshi_config_selected(GSimpleAction *action, GVariant *param, g_settings_set_boolean(state->settings, "save-kanshi-config", g_variant_get_boolean(param)); } state->save_kanshi_config = g_variant_get_boolean(param); + state->kanshi_dirty = false; g_simple_action_set_state(action, param); } #endif diff --git a/src/outputs.c b/src/outputs.c index 6cec53a..9d0ce60 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -54,8 +54,11 @@ static void config_handle_succeeded(void *data, zwlr_output_configuration_v1_destroy(config); wd_ui_apply_done(pending->state, pending->outputs); #ifdef WITH_KANSHI - if (pending->manual && pending->state->save_kanshi_config) { - wd_store_config(pending->state, pending->outputs); + if (pending->state->save_kanshi_config) { + if (pending->manual) { + wd_store_config(pending->state, pending->outputs); + } + pending->state->kanshi_dirty = !pending->manual; } #endif destroy_pending(pending); diff --git a/src/store.c b/src/store.c index 4e6a166..8b9fe8a 100644 --- a/src/store.c +++ b/src/store.c @@ -634,6 +634,37 @@ static void reload_kanshi(void) { } } +void wd_store_config_if_dirty(struct wd_state *state) { + if (!state->kanshi_dirty) { + return; + } + state->kanshi_dirty = false; + + struct wl_list outputs; + wl_list_init(&outputs); + struct wd_head *head; + wl_list_for_each(head, &state->heads, link) { + struct wd_head_config *output = g_new0(struct wd_head_config, 1); + output->head = head; + output->enabled = head->enabled; + output->width = head->mode != NULL ? head->mode->width : head->custom_mode.width; + output->height = head->mode != NULL ? head->mode->height : head->custom_mode.height; + output->refresh = head->mode != NULL ? head->mode->refresh : head->custom_mode.refresh; + output->x = head->x; + output->y = head->y; + output->scale = head->scale; + output->transform = head->transform; + wl_list_insert(&outputs, &output->link); + } + wd_store_config(state, &outputs); + + struct wd_head_config *output, *tmp; + wl_list_for_each_safe(output, tmp, &outputs, link) { + wl_list_remove(&output->link); + g_free(output); + } +} + 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); diff --git a/src/wdisplays.h b/src/wdisplays.h index dbdb03d..ffec9bf 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -192,6 +192,7 @@ struct wd_state { bool capture; bool show_overlay; bool save_kanshi_config; + bool kanshi_dirty; double zoom; unsigned int apply_idle; @@ -352,6 +353,12 @@ void wd_destroy_overlay(struct wd_output *output); * Updates kanshi config */ void wd_store_config(struct wd_state *state, struct wl_list *outputs); + +/* + * Saves the current layout to the kanshi config if auto-apply changed it + * since the last save. + */ +void wd_store_config_if_dirty(struct wd_state *state); #endif // SPDX-SnippetEnd #endif From aa9b98df594d8895512a7e3a8a9eb45ecc6bcd5c Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:20:50 +0100 Subject: [PATCH 33/38] Keep auto-apply snapshots and save them on hotplug --- src/main.c | 6 +++--- src/outputs.c | 39 +++++++++++++++++++++++++++++++++++---- src/store.c | 31 ------------------------------- src/wdisplays.h | 13 +++++++++---- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/main.c b/src/main.c index 86052cb..fc89247 100644 --- a/src/main.c +++ b/src/main.c @@ -426,7 +426,7 @@ static void cleanup(GtkWidget *window, gpointer data) { g_object_unref(state->move_cursor); g_clear_object(&state->settings); #ifdef WITH_KANSHI - wd_store_config_if_dirty(state); + wd_save_kanshi_snapshot(state); #endif wd_state_destroy(state); } @@ -875,7 +875,7 @@ static void auto_apply_selected(GSimpleAction *action, GVariant *param, gpointer g_simple_action_set_state(action, param); #ifdef WITH_KANSHI if (!state->autoapply) { - wd_store_config_if_dirty(state); + wd_save_kanshi_snapshot(state); } #endif } @@ -925,7 +925,7 @@ static void save_kanshi_config_selected(GSimpleAction *action, GVariant *param, g_settings_set_boolean(state->settings, "save-kanshi-config", g_variant_get_boolean(param)); } state->save_kanshi_config = g_variant_get_boolean(param); - state->kanshi_dirty = false; + wd_drop_kanshi_snapshot(state); g_simple_action_set_state(action, param); } #endif diff --git a/src/outputs.c b/src/outputs.c index 9d0ce60..8d5662f 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -38,16 +38,38 @@ struct wd_pending_config { bool manual; }; -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) { + 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; @@ -55,10 +77,13 @@ static void config_handle_succeeded(void *data, wd_ui_apply_done(pending->state, pending->outputs); #ifdef WITH_KANSHI if (pending->state->save_kanshi_config) { + 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; } - pending->state->kanshi_dirty = !pending->manual; } #endif destroy_pending(pending); @@ -491,6 +516,9 @@ 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 wl_list_remove(&head->link); wd_head_destroy(head); @@ -526,6 +554,9 @@ 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; diff --git a/src/store.c b/src/store.c index 8b9fe8a..4e6a166 100644 --- a/src/store.c +++ b/src/store.c @@ -634,37 +634,6 @@ static void reload_kanshi(void) { } } -void wd_store_config_if_dirty(struct wd_state *state) { - if (!state->kanshi_dirty) { - return; - } - state->kanshi_dirty = false; - - struct wl_list outputs; - wl_list_init(&outputs); - struct wd_head *head; - wl_list_for_each(head, &state->heads, link) { - struct wd_head_config *output = g_new0(struct wd_head_config, 1); - output->head = head; - output->enabled = head->enabled; - output->width = head->mode != NULL ? head->mode->width : head->custom_mode.width; - output->height = head->mode != NULL ? head->mode->height : head->custom_mode.height; - output->refresh = head->mode != NULL ? head->mode->refresh : head->custom_mode.refresh; - output->x = head->x; - output->y = head->y; - output->scale = head->scale; - output->transform = head->transform; - wl_list_insert(&outputs, &output->link); - } - wd_store_config(state, &outputs); - - struct wd_head_config *output, *tmp; - wl_list_for_each_safe(output, tmp, &outputs, link) { - wl_list_remove(&output->link); - g_free(output); - } -} - 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); diff --git a/src/wdisplays.h b/src/wdisplays.h index ffec9bf..90f039f 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -192,7 +192,7 @@ struct wd_state { bool capture; bool show_overlay; bool save_kanshi_config; - bool kanshi_dirty; + struct wl_list *kanshi_snapshot; double zoom; unsigned int apply_idle; @@ -355,10 +355,15 @@ void wd_destroy_overlay(struct wd_output *output); void wd_store_config(struct wd_state *state, struct wl_list *outputs); /* - * Saves the current layout to the kanshi config if auto-apply changed it - * since the last save. + * Saves the layout of the last auto-apply to the kanshi config, unless it + * has been saved or dropped already. */ -void wd_store_config_if_dirty(struct wd_state *state); +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 // SPDX-SnippetEnd #endif From 9cd13c02c3605ba64e7fb2db9f62445423bb6e4c Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:28:46 +0100 Subject: [PATCH 34/38] Save kanshi snapshot ahead of mode removal --- src/outputs.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/outputs.c b/src/outputs.c index 8d5662f..b0d7c44 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -400,6 +400,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 wl_list_remove(&mode->link); wd_mode_destroy(mode); } From 91f8ca2fad54787bddcc8ee059b3830df27d2ef5 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:05:25 +0100 Subject: [PATCH 35/38] Drop removed heads from applies still in flight --- src/outputs.c | 22 +++++++++++++++++++++- src/wdisplays.h | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/outputs.c b/src/outputs.c index b0d7c44..71c6d52 100644 --- a/src/outputs.c +++ b/src/outputs.c @@ -35,7 +35,9 @@ 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_outputs(struct wl_list *outputs) { @@ -48,6 +50,7 @@ static void destroy_outputs(struct wl_list *outputs) { } static void destroy_pending(struct wd_pending_config *pending) { + wl_list_remove(&pending->link); if (pending->outputs != NULL) { destroy_outputs(pending->outputs); } @@ -76,7 +79,7 @@ static void config_handle_succeeded(void *data, zwlr_output_configuration_v1_destroy(config); wd_ui_apply_done(pending->state, pending->outputs); #ifdef WITH_KANSHI - if (pending->state->save_kanshi_config) { + if (pending->state->save_kanshi_config && !pending->incomplete) { wd_drop_kanshi_snapshot(pending->state); if (pending->manual) { wd_store_config(pending->state, pending->outputs); @@ -125,6 +128,7 @@ void wd_apply_state(struct wd_state *state, struct wl_list *new_outputs, 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); @@ -515,6 +519,20 @@ static void head_handle_serial_number(void *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; @@ -522,6 +540,7 @@ static void head_handle_finished(void *data, #ifdef WITH_KANSHI wd_save_kanshi_snapshot(state); #endif + remove_pending_head(state, head); wl_list_remove(&head->link); wd_head_destroy(head); @@ -732,6 +751,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/wdisplays.h b/src/wdisplays.h index 90f039f..c3eb334 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -184,6 +184,7 @@ 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; From f4db37a26648495e7a86862a3b074eda46d0dc7a Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:03:57 +0200 Subject: [PATCH 36/38] Declare kanshi snapshot functions outside MIT code --- src/wdisplays.h | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/wdisplays.h b/src/wdisplays.h index c3eb334..15636ce 100644 --- a/src/wdisplays.h +++ b/src/wdisplays.h @@ -277,6 +277,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,17 +367,6 @@ void wd_destroy_overlay(struct wd_output *output); * Updates kanshi config */ void wd_store_config(struct wd_state *state, struct wl_list *outputs); - -/* - * 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 // SPDX-SnippetEnd #endif From eb836a99fbc815100cac4a55ffc820d6d7028180 Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:51:02 +0200 Subject: [PATCH 37/38] Remove .vscode from .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2122d6d..48835df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /build/ -.vscode *.user From 4636a71c8ccee000a2b2bd1d1637f4690b6a011c Mon Sep 17 00:00:00 2001 From: Mars-Wave <57585293+Mars-Wave@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:32:53 +0200 Subject: [PATCH 38/38] Write scales with as few digits as they need --- src/store.c | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/store.c b/src/store.c index 4e6a166..2e8ab97 100644 --- a/src/store.c +++ b/src/store.c @@ -389,6 +389,19 @@ static bool is_custom_mode(struct wd_head_config *output) { 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) { @@ -405,7 +418,7 @@ static void append_settings(GString *str, struct wd_head_config *output, g_string_append_printf(str, "@%sHz", refresh); } char scale[G_ASCII_DTOSTR_BUF_SIZE]; - g_ascii_dtostr(scale, sizeof(scale), output->scale); + 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)); }