From 85b6e25484b265316948090b2dd8e958679d5a27 Mon Sep 17 00:00:00 2001 From: tokyo4j Date: Sun, 18 Aug 2024 12:22:07 +0900 Subject: [PATCH] config: support rc.yaml Based on @johanmalm's work. This adds libyaml as an optional dependency. --- README.md | 9 +- docs/README | 1 + docs/labwc-config.5.scd | 87 +++++++++++++++-- docs/meson.build | 3 +- docs/rc.yaml | 89 +++++++++++++++++ include/common/yaml2xml.h | 9 ++ meson.build | 13 +++ meson_options.txt | 1 + src/common/meson.build | 6 ++ src/common/yaml2xml.c | 194 ++++++++++++++++++++++++++++++++++++++ src/config/rcxml.c | 75 ++++++++++++--- t/meson.build | 33 +++++-- t/yaml2xml.c | 99 +++++++++++++++++++ 13 files changed, 588 insertions(+), 31 deletions(-) create mode 100644 docs/rc.yaml create mode 100644 include/common/yaml2xml.h create mode 100644 src/common/yaml2xml.c create mode 100644 t/yaml2xml.c diff --git a/README.md b/README.md index 88ed65ad..c58e0a56 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ modification. Openbox spec is somewhat of a stable standard considering how long it has remained unchanged for and how wide-spread its adoption is by lightweight -distributions such as LXDE, LXQt, BunsenLabs, ArchLabs, Mabox and Raspbian. Some -widely used themes (for example Numix and Arc) have built-in support. +distributions such as LXDE, LXQt, BunsenLabs, ArchLabs, Mabox and Raspbian. +Some widely used themes (for example Numix and Arc) have built-in support. We could have invented a whole new syntax, but that's not where we want to spend our effort. @@ -182,8 +182,8 @@ prevent installing the wlroots headers: ## 3. Configuration User config files are located at `${XDG_CONFIG_HOME:-$HOME/.config/labwc/}` -with the following five files being used: [rc.xml], [menu.xml], [autostart], [shutdown], -[environment] and [themerc-override]. +with the following five files being used: [rc.xml] (or [rc.yaml]), [menu.xml], +[autostart], [shutdown], [environment] and [themerc-override]. Run `labwc --reconfigure` to reload configuration and theme. @@ -275,6 +275,7 @@ See [integration] for further details. [metacity]: https://github.com/GNOME/metacity [rc.xml]: docs/rc.xml.all +[rc.yaml]: docs/rc.yaml [menu.xml]: docs/menu.xml [autostart]: docs/autostart [shutdown]: docs/shutdown diff --git a/docs/README b/docs/README index c90c047a..769118e0 100644 --- a/docs/README +++ b/docs/README @@ -3,6 +3,7 @@ Config layout for ~/.config/labwc/ - environment - menu.xml - rc.xml +- rc.yaml - shutdown - themerc-override - xinitrc diff --git a/docs/labwc-config.5.scd b/docs/labwc-config.5.scd index 6da0af34..53806cdc 100644 --- a/docs/labwc-config.5.scd +++ b/docs/labwc-config.5.scd @@ -8,7 +8,8 @@ labwc - configuration files Labwc uses openbox-3.6 specification for configuration and theming, but does not support all options. The following files form the basis of the labwc -configuration: rc.xml, menu.xml, autostart, shutdown, environment and xinitrc. +configuration: rc.xml (or rc.yaml), menu.xml, autostart, shutdown, environment +and xinitrc. No configuration files are needed to start and run labwc. @@ -158,6 +159,77 @@ Note that in this manual, Boolean values are listed as [yes|no] for simplicity, but it's also possible to use [true|false] and\/or [on|off]; this is for compatibility with Openbox. +## YAML SUPPORT + +Labwc also supports YAML language for configuration. When rc.yaml exists +instead of rc.xml, labwc internally converts it from YAML into XML and loads +configurations from it. For example, "foo: bar" in YAML is converted to +"bar" in XML. See /usr/share/docs/labwc/rc.yaml for an example +configuration in YAML. + +If rc.yaml includes a key-value pair where the value is an array, it is +converted to a sequence of array-element in XML. + +For example, a YAML expression: + +``` +touch: + - deviceName: xxxx + mapToOutput: eDP-1 + - deviceName: yyyy + mapToOutput: HDMI-1 +``` + +is converted to an XML expression: + +``` + + xxxx + eDP-1 + + + yyyy + eDP-1 + +``` + +To avoid unnecessary indentations, some nodes that wrap array elements in XML +can be ommitted. This includes: + + - ** + - ** + - ** + - ** + - ** + +For example, window switcher can be configured like: + +``` +windowSwitcher: + fields: + - content: type + width: 15% + - content: title + width: 85% +``` + +In addition, some specific keys in singular form with a sequence value in YAML +are converted to plural form in XML. This includes: + + - *keybinds* (converted to *keybind*) + - *mousebinds* (converted to *mousebind*) + - *actions* (converted to *action*) + - *fonts* (converted to *font*) + - *contexts* (converted to *context*) + +For example, keybinds can be configured like: + +``` +keybinds: + - { key: W-s, action: { name: Execute, command: foot } } + - { key: W-a, action: { name: Execute, command: fuzzel } } +``` + ## CORE ``` @@ -573,9 +645,9 @@ extending outward from the snapped edge. Load the default keybinds listed below. This is an addition to the openbox specification and provides a way to keep config files simpler whilst allowing your specific keybinds. - Note that if no rc.xml is found, or if no entries - exist, the same default keybinds will be loaded even if the - element is not provided. + Note that if no rc.xml or rc.yaml is found, or if no + entries exist, the same default keybinds will be loaded even if the + element is not provided. ``` A-Tab - next window @@ -664,9 +736,10 @@ extending outward from the snapped edge. ** Load default mousebinds. This is an addition to the openbox specification and provides a way to keep config files simpler whilst - allowing user specific binds. Note that if no rc.xml is found, or if no - entries exist, the same default mousebinds will be - loaded even if the element is not provided. + allowing user specific binds. Note that if no rc.xml or rc.yaml is + found, or if no entries exist, the same default + mousebinds will be loaded even if the element is not + provided. ## TOUCH diff --git a/docs/meson.build b/docs/meson.build index a6676784..4691578e 100644 --- a/docs/meson.build +++ b/docs/meson.build @@ -33,7 +33,8 @@ install_data( 'shutdown', 'themerc', 'rc.xml', - 'rc.xml.all' + 'rc.xml.all', + 'rc.yaml', ], install_dir: get_option('datadir') / 'doc' / meson.project_name() ) diff --git a/docs/rc.yaml b/docs/rc.yaml new file mode 100644 index 00000000..299460ff --- /dev/null +++ b/docs/rc.yaml @@ -0,0 +1,89 @@ +# An example configuration file in YAML. To see all the configurations and +# their descriptions, see docs/rc.xml.all or labwc-config(5). +core: + xwaylandPersistence: yes +placement: + policy: cascade +theme: + dropShadows: yes + fonts: + - place: ActiveWindow + weight: bold + - place: InactiveWindow + weight: normal +desktops: + number: 2 +windowSwitcher: + fields: + - content: type + width: 15% + - content: title + width: 85% +resize: + drawContents: no +focus: + followMouse: yes + followMouseRequiresMovement: yes + raiseOnFocus: no +snapping: + range: 20 + overlay: + delay: + inner: 100 + outer: 0 +regions: + - { name: top-left, x: 0%, y: 0%, width: 50%, height: 50% } + - { name: top-right, x: 50%, y: 0%, width: 50%, height: 50% } + - { name: bottom-left, x: 0%, y: 50%, width: 50%, height: 50% } + - { name: bottom-right, x: 50%, y: 50%, width: 50%, height: 50% } +keyboard: + repeatRate: 25 + repeatDelay: 600 + keybinds: + - { key: W-bracketRight, action: { name: ZoomIn } } + - { key: W-bracketLeft, action: { name: ZoomOut } } + - { key: W-d, action: { name: Debug } } + - { key: W-1, action: { name: GoToDesktop, to: Workspace 1 } } + - { key: W-2, action: { name: GoToDesktop, to: Workspace 2 } } + - { key: W-m, action: { name: ToggleKeybinds } } + - { key: W-q, action: { name: Close } } + - { key: A-F4, action: { name: Close } } + - { key: A-Tab, action: { name: NextWindow } } + - { key: W-e, action: { name: Exit } } + - { key: W-v, action: { name: Execute, command: sh -c 'cliphist list | fuzzel --dmenu | cliphist decode | wl-copy' } } + - { key: W-Tab, action: { name: ToggleMaximize } } + - { key: W-s, action: { name: Execute, command: foot } } + - { key: W-a, action: { name: Execute, command: fuzzel } } + - { key: XF86_AudioLowerVolume, action: { name: Execute, command: pactl set-sink-volume @DEFAULT_SINK@ -2dB } } + - { key: XF86_AudioRaiseVolume, action: { name: Execute, command: pactl set-sink-volume @DEFAULT_SINK@ +2dB } } + - { key: XF86_AudioMute, action: { name: Execute, command: pactl set-sink-mute @DEFAULT_SINK@ toggle } } + - { key: XF86_AudioMicMute, action: { name: Execute, command: pactl set-source-mute @DEFAULT_SOURCE@ toggle } } + - { key: Print, action: { name: Execute, command: grim } } +mouse: + doubleClickTime: 200 + default: + contexts: + - name: Frame + mousebinds: + - { button: W-Left, action: Press, actions: [ { name: Raise }, { name: Move } ] } + - { button: W-Right, action: Drag, action: { name: Raise } } + - { button: A-Left, action: Press } + - { button: A-Left, action: Drag } + - { button: A-Right, action: Press } + - { button: A-Right, action: Drag } +touch: + deviceName: ELAN2514:00 04F3:2AF1 + mapToOutput: eDP-1 +tablet: + mapToOutput: eDP-1 +libinput: + - category: non-touch + pointerSpeed: -0.4 + - category: touchpad + naturalScroll: yes + tapAndDrag: no +windowRules: + - identifier: ristretto + serverDecoration: no + - identifier: code-url-handler + action: { name: SetDecorations, decorations: border, forceSSD: yes } diff --git a/include/common/yaml2xml.h b/include/common/yaml2xml.h new file mode 100644 index 00000000..8c1a27ca --- /dev/null +++ b/include/common/yaml2xml.h @@ -0,0 +1,9 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#ifndef LABWC_YAML2XML_H +#define LABWC_YAML2XML_H +#include +#include + +struct buf yaml_to_xml(FILE *stream, const char *toplevel_name); + +#endif /* LABWC_YAML2XML_H */ diff --git a/meson.build b/meson.build index 8f9b5a97..da2f449d 100644 --- a/meson.build +++ b/meson.build @@ -72,6 +72,7 @@ pixman = dependency('pixman-1') math = cc.find_library('m') png = dependency('libpng') svg = dependency('librsvg-2.0', version: '>=2.46', required: false) +yaml = dependency('yaml-0.1', required: false) if get_option('xwayland').enabled() and not wlroots_has_xwayland error('no wlroots Xwayland support') @@ -87,6 +88,13 @@ else endif conf_data.set10('HAVE_RSVG', have_rsvg) +if get_option('yaml').disabled() + have_libyaml = false +else + have_libyaml = yaml.found() +endif +conf_data.set10('HAVE_LIBYAML', have_libyaml) + if get_option('static_analyzer').enabled() add_project_arguments(['-fanalyzer'], language: 'c') endif @@ -125,6 +133,11 @@ if have_rsvg svg, ] endif +if have_libyaml + labwc_deps += [ + yaml, + ] +endif subdir('include') subdir('src') diff --git a/meson_options.txt b/meson_options.txt index 4d6e8cd5..3b6b13b8 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -4,3 +4,4 @@ option('svg', type: 'feature', value: 'enabled', description: 'Enable svg window option('nls', type: 'feature', value: 'auto', description: 'Enable native language support') option('static_analyzer', type: 'feature', value: 'disabled', description: 'Run gcc static analyzer') option('test', type: 'feature', value: 'disabled', description: 'Run tests') +option('yaml', type: 'feature', value: 'enabled', description: 'Enable configuration in YAML') diff --git a/src/common/meson.build b/src/common/meson.build index 1569f775..18488155 100644 --- a/src/common/meson.build +++ b/src/common/meson.build @@ -19,3 +19,9 @@ labwc_sources += files( 'spawn.c', 'string-helpers.c', ) + +if have_libyaml + labwc_sources += files( + 'yaml2xml.c', + ) +endif diff --git a/src/common/yaml2xml.c b/src/common/yaml2xml.c new file mode 100644 index 00000000..5ac1e6e5 --- /dev/null +++ b/src/common/yaml2xml.c @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include +#include +#include +#include +#include +#include +#include +#include "common/buf.h" +#include "common/yaml2xml.h" + +struct lab_yaml_event { + yaml_event_type_t type; + char scalar[256]; +}; + +static bool process_mapping(yaml_parser_t *parser, struct buf *buf); +static bool process_sequence(yaml_parser_t *parser, struct buf *buf, char *key_name); + +static bool +parse(yaml_parser_t *parser, struct lab_yaml_event *event) +{ + yaml_event_t yaml_event; + if (!yaml_parser_parse(parser, &yaml_event)) { + wlr_log(WLR_ERROR, + "Conversion from YAML to XML failed at %ld:%ld: %s", + parser->problem_mark.line + 1, + parser->problem_mark.column, + parser->problem); + return false; + } + event->type = yaml_event.type; + if (yaml_event.type == YAML_SCALAR_EVENT) { + snprintf(event->scalar, sizeof(event->scalar), "%s", + (char *)yaml_event.data.scalar.value); + } + yaml_event_delete(&yaml_event); + return event; +} + +static bool +process_value(yaml_parser_t *parser, struct buf *b, + struct lab_yaml_event *event, char *key_name) +{ + const char *parent_name = NULL; + + switch (event->type) { + case YAML_MAPPING_START_EVENT: + buf_add_fmt(b, "<%s>", key_name); + if (!process_mapping(parser, b)) { + return false; + } + buf_add_fmt(b, "", key_name); + break; + case YAML_SEQUENCE_START_EVENT: + /* + * YAML XML + * fields: [x,y] -> xy + */ + if (!strcasecmp(key_name, "fields")) { + parent_name = "fields"; + key_name = "field"; + } else if (!strcasecmp(key_name, "regions")) { + parent_name = "regions"; + key_name = "region"; + } else if (!strcasecmp(key_name, "windowRules")) { + parent_name = "windowRules"; + key_name = "windowRule"; + } else if (!strcasecmp(key_name, "libinput")) { + parent_name = "libinput"; + key_name = "device"; + } else if (!strcasecmp(key_name, "names")) { + parent_name = "names"; + key_name = "name"; + } else if (!strcasecmp(key_name, "keybinds")) { + key_name = "keybind"; + } else if (!strcasecmp(key_name, "mousebinds")) { + key_name = "mousebind"; + } else if (!strcasecmp(key_name, "actions")) { + key_name = "action"; + } else if (!strcasecmp(key_name, "fonts")) { + key_name = "font"; + } else if (!strcasecmp(key_name, "contexts")) { + key_name = "context"; + } + if (parent_name) { + buf_add_fmt(b, "<%s>", parent_name); + } + if (!process_sequence(parser, b, key_name)) { + return false; + } + if (parent_name) { + buf_add_fmt(b, "", parent_name); + } + break; + case YAML_SCALAR_EVENT: + /* + * To avoid duplicated keys, mousebind: {event: Press} is + * mapped to . + */ + if (!strcasecmp(key_name, "event")) { + key_name = "action"; + } + buf_add_fmt(b, "<%s>%s", key_name, event->scalar, key_name); + break; + default: + break; + } + return true; +} + +static bool +process_sequence(yaml_parser_t *parser, struct buf *b, char *key_name) +{ + while (1) { + struct lab_yaml_event event; + if (!parse(parser, &event)) { + return false; + } + if (event.type == YAML_SEQUENCE_END_EVENT) { + return true; + } + if (!process_value(parser, b, &event, key_name)) { + return false; + } + } +} + +static bool +process_mapping(yaml_parser_t *parser, struct buf *buf) +{ + while (1) { + struct lab_yaml_event key_event, value_event; + + if (!parse(parser, &key_event)) { + return false; + } + if (key_event.type == YAML_MAPPING_END_EVENT) { + return true; + } + if (key_event.type != YAML_SCALAR_EVENT) { + wlr_log(WLR_ERROR, "key must be scalar"); + return false; + } + if (!parse(parser, &value_event)) { + return false; + } + if (!process_value(parser, buf, &value_event, key_event.scalar)) { + return false; + } + continue; + } +} + +static bool +process_root(yaml_parser_t *parser, struct buf *b, const char *toplevel_name) +{ + struct lab_yaml_event event; + if (!parse(parser, &event) || event.type != YAML_STREAM_START_EVENT) { + return false; + } + if (!parse(parser, &event) || event.type != YAML_DOCUMENT_START_EVENT) { + return false; + } + if (!parse(parser, &event) || event.type != YAML_MAPPING_START_EVENT) { + wlr_log(WLR_ERROR, "mapping is expected for toplevel node"); + return false; + } + + buf_add_fmt(b, "<%s>", toplevel_name); + if (!process_mapping(parser, b)) { + return false; + } + buf_add_fmt(b, "", toplevel_name); + return true; +} + +struct buf +yaml_to_xml(FILE *stream, const char *toplevel_name) +{ + struct buf b = BUF_INIT; + yaml_parser_t parser; + yaml_parser_initialize(&parser); + yaml_parser_set_input_file(&parser, stream); + + bool success = process_root(&parser, &b, toplevel_name); + if (success) { + wlr_log(WLR_DEBUG, "XML converted from YAML: %s", b.data); + } else { + buf_reset(&b); + } + yaml_parser_delete(&parser); + return b; +} diff --git a/src/config/rcxml.c b/src/config/rcxml.c index fa4163c7..b3f0d8f3 100644 --- a/src/config/rcxml.c +++ b/src/config/rcxml.c @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-only #define _POSIX_C_SOURCE 200809L #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include "action.h" +#include "common/buf.h" #include "common/dir.h" #include "common/list.h" #include "common/macros.h" @@ -36,6 +38,10 @@ #include "window-rules.h" #include "workspaces.h" +#if HAVE_LIBYAML +#include "common/yaml2xml.h" +#endif + static bool in_regions; static bool in_usable_area_override; static bool in_keybind; @@ -1743,6 +1749,37 @@ validate(void) } } +static bool +file_is_xml(const char *filename) +{ + if (str_endswith(filename, ".xml")) { + return true; + } else if (str_endswith(filename, ".yaml")) { + return false; + } + + /* + * If the extension is not .xml or .yaml, judge by whther the content + * starts with '<'. + */ + FILE *stream = fopen(filename, "r"); + if (!stream) { + return true; + } + char c; + while ((c = fgetc(stream))) { + if (!isspace(c)) { + break; + } + } + fclose(stream); + if (c == '<') { + return true; + } else { + return false; + } +} + void rcxml_read(const char *filename) { @@ -1758,9 +1795,14 @@ rcxml_read(const char *filename) wl_list_append(&paths, &path->link); } else { paths_config_create(&paths, "rc.xml"); +#ifdef HAVE_LIBYAML + struct wl_list paths_yaml; + wl_list_init(&paths_yaml); + paths_config_create(&paths_yaml, "rc.yaml"); + wl_list_insert_list(paths.prev, &paths_yaml); +#endif } - /* Reading file into buffer before parsing - better for unit tests */ bool should_merge_config = rc.merge_config; struct wl_list *(*iter)(struct wl_list *list); iter = should_merge_config ? paths_get_prev : paths_get_next; @@ -1782,21 +1824,30 @@ rcxml_read(const char *filename) continue; } - wlr_log(WLR_INFO, "read config file %s", path->string); - struct buf b = BUF_INIT; - char *line = NULL; - size_t len = 0; - while (getline(&line, &len, stream) != -1) { - char *p = strrchr(line, '\n'); - if (p) { - *p = '\0'; + if (HAVE_LIBYAML && !file_is_xml(path->string)) { +#if HAVE_LIBYAML + wlr_log(WLR_INFO, "read yaml config file %s", path->string); + b = yaml_to_xml(stream, "labwc_config"); +#endif + } else { + wlr_log(WLR_INFO, "read xml config file %s", path->string); + char *line = NULL; + size_t len = 0; + while (getline(&line, &len, stream) != -1) { + char *p = strrchr(line, '\n'); + if (p) { + *p = '\0'; + } + buf_add(&b, line); } - buf_add(&b, line); + zfree(line); } - zfree(line); + fclose(stream); - rcxml_parse_xml(&b); + if (b.len > 0) { + rcxml_parse_xml(&b); + } buf_reset(&b); if (!should_merge_config) { break; diff --git a/t/meson.build b/t/meson.build index 5517b973..9eb99bf3 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,18 +1,37 @@ -test_lib = static_library( - 'test_lib', - sources: files( +test_lib_sources = files( '../src/common/buf.c', '../src/common/mem.c', - '../src/common/string-helpers.c' - ), - include_directories: [labwc_inc], - dependencies: [dep_cmocka], + '../src/common/string-helpers.c', ) +test_deps = [ + dep_cmocka, +] + tests = [ 'buf-simple', ] +if have_libyaml + test_lib_sources += [ + '../src/common/yaml2xml.c', + ] + test_deps += [ + wlroots, + yaml, + ] + tests += [ + 'yaml2xml', + ] +endif + +test_lib = static_library( + 'test_lib', + sources: test_lib_sources, + include_directories: [labwc_inc], + dependencies: test_deps +) + foreach t : tests test( 'test_@0@'.format(t), diff --git a/t/yaml2xml.c b/t/yaml2xml.c new file mode 100644 index 00000000..dd87ca7d --- /dev/null +++ b/t/yaml2xml.c @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-2.0-only +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include "common/buf.h" +#include "common/macros.h" +#include "common/yaml2xml.h" + +struct test_set { + const char *name, *yaml, *xml; +}; + +const struct test_set test_sets[] = { + { + .name = "key-scalar", + .yaml = "xxx: yyy", + .xml = "yyy", + }, + { + .name = "key-sequence", + .yaml = "xxx: [yyy, zzz]", + .xml = "yyyzzz", + }, + { + .name = "key-mapping", + .yaml = "xxx: {yyy: zzz}", + .xml = "zzz", + }, + { + .name = "window-switcher-fields", + .yaml = "windowSwitcher: {fields: [xxx, yyy]}", + .xml = + "" + "xxx" + "yyy" + "", + }, + { + .name = "theme-fonts", + .yaml = "theme: {fonts: [xxx, yyy]}", + .xml = + "" + "xxx" + "yyy" + "", + }, + { + .name = "mousebinds", + .yaml = + "mousebinds:\n" + " - { button: W-Left, action: Press, actions: [ { name: Raise }, { name: Move } ] }\n" + " - { button: W-Right, action: Drag, action: { name: Resize} }\n", + .xml = + "" + "" + "" + "Press" + "Raise" + "Move" + "" + "" + "" + "Drag" + "Resize" + "" + "", + }, +}; + +static void +test_yaml_to_xml(void **state) +{ + (void)state; + for (int i = 0; i < (int)ARRAY_SIZE(test_sets); i++) { + const struct test_set *set = &test_sets[i]; + + char buf[1024]; + FILE *stream = fmemopen(buf, sizeof(buf), "w+"); + fwrite(set->yaml, strlen(set->yaml), 1, stream); + fseek(stream, 0, SEEK_SET); + + struct buf b = yaml_to_xml(stream, "test"); + fclose(stream); + assert_string_equal(b.data, set->xml); + } +} + +int main(int argc, char **argv) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_yaml_to_xml), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}