From 8ef7dee446b3bbf7559f3afdd890308bee06f6f6 Mon Sep 17 00:00:00 2001 From: ebenali <54529923+ebenali@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:20:05 +0000 Subject: [PATCH 1/2] feat(wbconf): add a GTK4 settings GUI (obconf-style) + fix C++ i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce wbconf, an optional graphical configuration tool for waybox modelled after Openbox's obconf. It edits the user's rc.xml in place through a small, unit-tested libxml2 model and asks a running waybox to reload (SIGUSR2). - wb::RcDocument (waybox/wbconf.cpp): loads rc.xml, reads/writes only the settings wbconf manages — standard Openbox keys (theme/name, placement/policy, margins) and the waybox extensions (titlebar/menu/switcher attributes on children) — preserving keybindings, app rules and comments. Round-trip unit-tested (test/wbconf_test.cpp). - wb::installed_theme_names(): enumerate themes with an openbox-3/themerc. - wbconf/main.cpp: GTK4 UI with obconf-style tabs (Appearance, Windows, Margins, Menu, Switcher), Save & Apply (writes ~/.config/waybox/rc.xml and signals waybox), built only when GTK4 is present (meson feature 'wbconf', auto). - Ships wbconf.desktop (validated). i18n: every wbconf string is wrapped in _() and the text domain initialised. The gettext build defines (USE_NLS/GETTEXT_PACKAGE/LOCALEDIR) were only applied to C, so after the C->C++ port the compositor's own _() strings were no longer translated at runtime; extend them to C++ and refresh POTFILES.in to the .cpp filenames plus wbconf, regenerating waybox.pot. Tests: 27 green (g++/ASan, incl. the new wbconf model test); clang + release clean; headless smoke shows the GUI maps and populates under real Wayland with no ASan/GTK errors; pot extraction includes the wbconf strings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- include/waybox/wbconf.hpp | 91 ++++++++ meson.build | 7 + meson_options.txt | 1 + po/POTFILES.in | 13 +- po/meson.build | 2 +- po/waybox.pot | 100 ++++++++- test/meson.build | 12 ++ test/wbconf_test.cpp | 115 ++++++++++ waybox/wbconf.cpp | 328 +++++++++++++++++++++++++++++ wbconf/main.cpp | 433 ++++++++++++++++++++++++++++++++++++++ wbconf/meson.build | 24 +++ wbconf/wbconf.desktop | 11 + 12 files changed, 1128 insertions(+), 9 deletions(-) create mode 100644 include/waybox/wbconf.hpp create mode 100644 test/wbconf_test.cpp create mode 100644 waybox/wbconf.cpp create mode 100644 wbconf/main.cpp create mode 100644 wbconf/meson.build create mode 100644 wbconf/wbconf.desktop diff --git a/include/waybox/wbconf.hpp b/include/waybox/wbconf.hpp new file mode 100644 index 0000000..7def0fd --- /dev/null +++ b/include/waybox/wbconf.hpp @@ -0,0 +1,91 @@ +#ifndef WB_WBCONF_HPP +#define WB_WBCONF_HPP + +#include +#include +#include + +namespace wb { + +/* + * The waybox settings that wbconf (our obconf-style GUI) reads from and writes + * to rc.xml. Every field is optional: std::nullopt means "absent from rc.xml", + * so the compositor falls back to its built-in default. Standard Openbox keys + * (theme/name, placement/policy, margins) live as child-element text; the + * waybox extensions (titlebar/menu/switcher) live as attributes on child + * elements of . + */ +struct WayboxSettings { + /* Appearance */ + std::optional theme_name; /* theme/name */ + std::optional titlebar_pad_y; /* waybox/titlebar@paddingY */ + std::optional titlebar_button_size; /* waybox/titlebar@buttonSize */ + std::optional titlebar_resize_grab; /* waybox/titlebar@resizeGrab */ + + /* Windows */ + std::optional placement_policy; /* placement/policy: + * Smart|Center|UnderMouse */ + + /* Margins (reserved screen-edge space, in px) */ + std::optional margin_top; /* margins/top */ + std::optional margin_bottom; /* margins/bottom */ + std::optional margin_left; /* margins/left */ + std::optional margin_right; /* margins/right */ + + /* Menu (waybox extension) */ + std::optional menu_source; /* builtin | */ + std::optional menu_submenu_open; /* hover | click */ + std::optional menu_hover_delay; /* ms */ + std::optional menu_wrap; + std::optional menu_icons; + + /* Task switcher (waybox extension) */ + std::optional switcher_order; /* mru | stacking | spatial */ + std::optional switcher_osd; + std::optional switcher_wrap; +}; + +/* + * A loaded rc.xml document. Reads/writes only the nodes wbconf manages, leaving + * the rest of the user's configuration (keybindings, application rules, …) and + * its comments intact. Backed by libxml2; movable, non-copyable. + */ +class RcDocument { +public: + RcDocument(RcDocument &&) noexcept; + RcDocument &operator=(RcDocument &&) noexcept; + RcDocument(const RcDocument &) = delete; + RcDocument &operator=(const RcDocument &) = delete; + ~RcDocument(); + + /* Parse rc.xml from a file or an in-memory string. Returns nullopt if the + * document cannot be parsed or has no root element. */ + static std::optional load_file(const std::string &path); + static std::optional from_string(const std::string &xml); + + /* The settings currently present in the document. */ + WayboxSettings read() const; + + /* Write the settings into the document: each engaged field is created or + * updated, each std::nullopt field is removed (so the default applies). */ + void apply(const WayboxSettings &settings); + + /* Serialise the whole document back to XML / to a file. */ + std::string to_string() const; + bool save_file(const std::string &path) const; + +private: + explicit RcDocument(void *doc) : doc_(doc) {} + void *doc_ = nullptr; /* xmlDocPtr, owned */ +}; + +/* + * The names of installed Openbox/waybox themes — every directory under the + * theme search paths that contains an openbox-3/themerc. Sorted, de-duplicated. + * Reads the real environment ($XDG_DATA_HOME, $HOME, $XDG_DATA_DIRS). + */ +std::vector installed_theme_names(); + +} // namespace wb + +#endif /* WB_WBCONF_HPP */ diff --git a/meson.build b/meson.build index 156a0d4..788355d 100644 --- a/meson.build +++ b/meson.build @@ -131,9 +131,16 @@ if not tests_only subdir('po') endif + # wbconf: an optional GTK4 settings GUI (obconf-style). Built only when GTK4 + # is available, so the compositor still builds on hosts without it. + gtk4 = dependency('gtk4', required: get_option('wbconf')) + subdir('data') subdir('protocol') subdir('waybox') + if gtk4.found() + subdir('wbconf') + endif endif subdir('test') diff --git a/meson_options.txt b/meson_options.txt index 8dda9dc..b428a65 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -2,3 +2,4 @@ option('wlroots-version', type: 'string', value: '', description: 'The version o option('sanitize', type: 'feature', value: 'auto', description: 'Build with AddressSanitizer and UndefinedBehaviorSanitizer (auto: enabled for debug buildtypes)') option('tests_only', type: 'boolean', value: false, description: 'Build only the dependency-free unit tests (no wlroots/compositor); used for a sanitized CI job on stock distros') option('svg', type: 'feature', value: 'auto', description: 'Build with librsvg for SVG menu-icon support (auto: enabled when librsvg is present)') +option('wbconf', type: 'feature', value: 'auto', description: 'Build the wbconf GTK4 settings GUI (auto: enabled when GTK4 is present)') diff --git a/po/POTFILES.in b/po/POTFILES.in index fd8f5b9..599e7f0 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,7 +1,8 @@ data/waybox.sh -waybox/config.c -waybox/main.c -waybox/output.c -waybox/seat.c -waybox/server.c -waybox/xdg_shell.c +waybox/config.cpp +waybox/main.cpp +waybox/output.cpp +waybox/seat.cpp +waybox/server.cpp +waybox/xdg_shell.cpp +wbconf/main.cpp diff --git a/po/meson.build b/po/meson.build index 022521d..55e346c 100644 --- a/po/meson.build +++ b/po/meson.build @@ -1,6 +1,6 @@ add_project_arguments('-DGETTEXT_PACKAGE="' + meson.project_name().to_lower() + '"', '-DLOCALEDIR="' + get_option('prefix') / get_option('localedir') + '"', - '-DUSE_NLS=1', language: 'c') + '-DUSE_NLS=1', language: ['c', 'cpp']) i18n = import('i18n', required: false) if i18n.found() diff --git a/po/waybox.pot b/po/waybox.pot index 682fbdf..9ed1090 100644 --- a/po/waybox.pot +++ b/po/waybox.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: waybox\n" "Report-Msgid-Bugs-To: https://github.com/wizbright/waybox/issues\n" -"POT-Creation-Date: 2024-01-25 21:21-0500\n" +"POT-Creation-Date: 2026-06-21 15:16+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,6 +20,9 @@ msgstr "" msgid "WARNING: Using files from Openbox. These may not work correctly." msgstr "" +msgid "ERROR: No menu file found." +msgstr "" + msgid "ERROR: No configuration file found." msgstr "" @@ -32,6 +35,9 @@ msgstr "" msgid "No nodeset" msgstr "" +msgid "Unable to determine the configuration file path." +msgstr "" + msgid "" "Unable to parse the configuration file. Consult stderr for more information." msgstr "" @@ -131,6 +137,12 @@ msgstr "" msgid "Failed to create allocator" msgstr "" +msgid "Failed to create seat" +msgstr "" + +msgid "Failed to create cursor" +msgstr "" + msgid "Failed to start backend" msgstr "" @@ -143,5 +155,89 @@ msgstr "" msgid "Keyboard focus is now on surface" msgstr "" -msgid "Focusing next toplevel" +msgid "Could not determine the rc.xml path." +msgstr "" + +msgid "Failed to write " +msgstr "" + +msgid "Saved and reloaded waybox." +msgstr "" + +msgid "Saved to " +msgstr "" + +msgid "(no running waybox to reload)." +msgstr "" + +msgid "Waybox Configuration" +msgstr "" + +msgid "Theme:" +msgstr "" + +msgid "Titlebar padding (px):" +msgstr "" + +msgid "Button size (px, 0 = auto):" +msgstr "" + +msgid "Resize grab margin (px):" +msgstr "" + +msgid "Appearance" +msgstr "" + +msgid "Placement:" +msgstr "" + +msgid "Windows" +msgstr "" + +msgid "Top:" +msgstr "" + +msgid "Bottom:" +msgstr "" + +msgid "Left:" +msgstr "" + +msgid "Right:" +msgstr "" + +msgid "Margins" +msgstr "" + +msgid "Source (builtin or command):" +msgstr "" + +msgid "Submenu opens on:" +msgstr "" + +msgid "Submenu hover delay (ms):" +msgstr "" + +msgid "Wrap selection:" +msgstr "" + +msgid "Show icons:" +msgstr "" + +msgid "Menu" +msgstr "" + +msgid "Order:" +msgstr "" + +msgid "Show OSD:" +msgstr "" + +msgid "Switcher" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Save & Apply" msgstr "" diff --git a/test/meson.build b/test/meson.build index 8e9a79f..33210e7 100644 --- a/test/meson.build +++ b/test/meson.build @@ -136,6 +136,18 @@ if not get_option('tests_only') test('menu_parse', menu_parse_test) endif +# wbconf rc.xml model round-trip test; links libxml2 + theme, full-build only. +if not get_option('tests_only') + wbconf_test = executable( + 'wbconf_test', + ['wbconf_test.cpp', 'wb_test_main.cpp', files('../waybox/wbconf.cpp'), + theme_src], + include_directories: [inc_dir], + dependencies: [libxml2], + ) + test('wbconf', wbconf_test) +endif + # Headless integration test driving real synthesized input via the # virtual-keyboard protocol. Skips (exit 77) when foot/wtype or a headless # display are unavailable, so CI stays green; run a sanitized local build to diff --git a/test/wbconf_test.cpp b/test/wbconf_test.cpp new file mode 100644 index 0000000..4fdde17 --- /dev/null +++ b/test/wbconf_test.cpp @@ -0,0 +1,115 @@ +#include "wb_test.hpp" + +#include + +#include "waybox/wbconf.hpp" + +using wb::RcDocument; +using wb::WayboxSettings; + +static const char *kMinimalRc = + "\n" + "\n" + " \n" + " \n" + " \n" + " Clearlooks\n" + "\n"; + +WB_TEST(reads_existing_values) { + auto doc = RcDocument::from_string(kMinimalRc); + WB_CHECK(doc.has_value()); + WayboxSettings s = doc->read(); + WB_CHECK(s.theme_name.has_value() && *s.theme_name == "Clearlooks"); + /* Nothing else is present yet. */ + WB_CHECK(!s.titlebar_pad_y.has_value()); + WB_CHECK(!s.switcher_order.has_value()); + WB_CHECK(!s.margin_top.has_value()); +} + +WB_TEST(round_trips_all_settings) { + auto doc = RcDocument::from_string(kMinimalRc); + WB_CHECK(doc.has_value()); + + WayboxSettings s; + s.theme_name = "Onyx-Citrus"; + s.titlebar_pad_y = 2; + s.titlebar_button_size = 0; + s.titlebar_resize_grab = 8; + s.placement_policy = "Center"; + s.margin_top = 24; + s.margin_bottom = 0; + s.margin_left = 4; + s.margin_right = 4; + s.menu_source = "builtin"; + s.menu_submenu_open = "hover"; + s.menu_hover_delay = 120; + s.menu_wrap = true; + s.menu_icons = false; + s.switcher_order = "mru"; + s.switcher_osd = true; + s.switcher_wrap = false; + doc->apply(s); + + /* Re-parse the serialised document and confirm every value survived. */ + std::string xml = doc->to_string(); + auto doc2 = RcDocument::from_string(xml); + WB_CHECK(doc2.has_value()); + WayboxSettings r = doc2->read(); + WB_CHECK(r.theme_name && *r.theme_name == "Onyx-Citrus"); + WB_CHECK(r.titlebar_pad_y && *r.titlebar_pad_y == 2); + WB_CHECK(r.titlebar_button_size && *r.titlebar_button_size == 0); + WB_CHECK(r.titlebar_resize_grab && *r.titlebar_resize_grab == 8); + WB_CHECK(r.placement_policy && *r.placement_policy == "Center"); + WB_CHECK(r.margin_top && *r.margin_top == 24); + WB_CHECK(r.margin_left && *r.margin_left == 4); + WB_CHECK(r.menu_source && *r.menu_source == "builtin"); + WB_CHECK(r.menu_submenu_open && *r.menu_submenu_open == "hover"); + WB_CHECK(r.menu_hover_delay && *r.menu_hover_delay == 120); + WB_CHECK(r.menu_wrap && *r.menu_wrap == true); + WB_CHECK(r.menu_icons && *r.menu_icons == false); + WB_CHECK(r.switcher_order && *r.switcher_order == "mru"); + WB_CHECK(r.switcher_osd && *r.switcher_osd == true); + WB_CHECK(r.switcher_wrap && *r.switcher_wrap == false); + + /* The unrelated keybinding must be preserved. */ + WB_CHECK(xml.find("NextWindow") != std::string::npos); +} + +WB_TEST(updates_in_place_without_duplicating) { + auto doc = RcDocument::from_string(kMinimalRc); + WB_CHECK(doc.has_value()); + WayboxSettings s = doc->read(); + s.theme_name = "Bear2"; + doc->apply(s); + std::string xml = doc->to_string(); + /* The theme name was replaced, not duplicated. */ + WB_CHECK(xml.find("Bear2") != std::string::npos); + WB_CHECK(xml.find("Clearlooks") == std::string::npos); + auto cnt = [](const std::string &h, const std::string &n) { + size_t c = 0, p = 0; + while ((p = h.find(n, p)) != std::string::npos) { ++c; p += n.size(); } + return c; + }; + WB_CHECK(cnt(xml, "") == 1); +} + +WB_TEST(clearing_removes_nodes_and_attrs) { + auto doc = RcDocument::from_string(kMinimalRc); + WB_CHECK(doc.has_value()); + + WayboxSettings set; + set.theme_name = "X"; + set.menu_icons = true; + doc->apply(set); + WB_CHECK(doc->to_string().find("icons=") != std::string::npos); + + /* Re-applying with the fields cleared removes them. */ + WayboxSettings cleared = doc->read(); + cleared.theme_name = std::nullopt; + cleared.menu_icons = std::nullopt; + doc->apply(cleared); + std::string xml = doc->to_string(); + WB_CHECK(xml.find("icons=") == std::string::npos); + WB_CHECK(xml.find("") == std::string::npos); +} diff --git a/waybox/wbconf.cpp b/waybox/wbconf.cpp new file mode 100644 index 0000000..653e151 --- /dev/null +++ b/waybox/wbconf.cpp @@ -0,0 +1,328 @@ +/* + * rc.xml model for wbconf: a thin libxml2 wrapper that reads and writes only + * the settings wbconf manages, preserving the rest of the user's configuration + * (keybindings, application rules) and its comments. The GTK UI binds to the + * plain WayboxSettings struct; this file is the XML glue and is unit-tested via + * in-memory round-trips (test/wbconf_test.cpp). + */ +#include "waybox/wbconf.hpp" + +#include +#include +#include + +#include +#include + +#include "waybox/theme.hpp" + +namespace wb { + +namespace { + +const xmlChar *xc(const char *s) { + return reinterpret_cast(s); +} + +/* Match an element child by (namespace-agnostic) name. */ +bool is_element(xmlNode *n, const char *name) { + return n != nullptr && n->type == XML_ELEMENT_NODE && + xmlStrcmp(n->name, xc(name)) == 0; +} + +/* The first element child of `parent` named `name`, or nullptr. */ +xmlNode *child_named(xmlNode *parent, const char *name) { + if (parent == nullptr) + return nullptr; + for (xmlNode *c = parent->children; c != nullptr; c = c->next) { + if (is_element(c, name)) + return c; + } + return nullptr; +} + +/* Find or create the element child `name` under `parent`, inheriting the + * document's default namespace so it serialises like the hand-written nodes. A + * little leading whitespace keeps the output readable. */ +xmlNode *ensure_child(xmlNode *parent, const char *name) { + if (xmlNode *existing = child_named(parent, name)) + return existing; + xmlNodePtr node = xmlNewNode(parent->ns, xc(name)); + if (node == nullptr) + return nullptr; + /* Indent the new node one level past its parent for legibility. */ + int depth = 0; + for (xmlNode *p = parent; p != nullptr && p->parent != nullptr; p = p->parent) + ++depth; + std::string indent = "\n" + std::string(static_cast(depth) * 2, ' '); + xmlAddChild(parent, xmlNewText(xc(indent.c_str()))); + xmlAddChild(parent, node); + return node; +} + +/* Walk/create a path of nested element names from the root, returning the leaf. */ +xmlNode *ensure_path(xmlNode *root, const std::vector &path) { + xmlNode *node = root; + for (const char *name : path) { + node = ensure_child(node, name); + if (node == nullptr) + return nullptr; + } + return node; +} + +/* Resolve an existing path of nested elements, or nullptr if any is missing. */ +xmlNode *find_path(xmlNode *root, const std::vector &path) { + xmlNode *node = root; + for (const char *name : path) { + node = child_named(node, name); + if (node == nullptr) + return nullptr; + } + return node; +} + +std::optional node_text(xmlNode *node) { + if (node == nullptr) + return std::nullopt; + xmlChar *content = xmlNodeGetContent(node); + if (content == nullptr) + return std::nullopt; + std::string value(reinterpret_cast(content)); + xmlFree(content); + return value; +} + +std::optional attr_value(xmlNode *node, const char *attr) { + if (node == nullptr) + return std::nullopt; + xmlChar *v = xmlGetProp(node, xc(attr)); + if (v == nullptr) + return std::nullopt; + std::string value(reinterpret_cast(v)); + xmlFree(v); + return value; +} + +std::optional to_int(const std::optional &s) { + if (!s) + return std::nullopt; + try { + size_t pos = 0; + int v = std::stoi(*s, &pos); + return v; + } catch (...) { + return std::nullopt; + } +} + +std::optional to_bool(const std::optional &s) { + if (!s) + return std::nullopt; + std::string v = *s; + std::transform(v.begin(), v.end(), v.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (v == "yes" || v == "true" || v == "1" || v == "on") + return true; + if (v == "no" || v == "false" || v == "0" || v == "off") + return false; + return std::nullopt; +} + +const char *bool_str(bool b) { return b ? "yes" : "no"; } + +} // namespace + +RcDocument::RcDocument(RcDocument &&o) noexcept : doc_(o.doc_) { + o.doc_ = nullptr; +} + +RcDocument &RcDocument::operator=(RcDocument &&o) noexcept { + if (this != &o) { + if (doc_ != nullptr) + xmlFreeDoc(static_cast(doc_)); + doc_ = o.doc_; + o.doc_ = nullptr; + } + return *this; +} + +RcDocument::~RcDocument() { + if (doc_ != nullptr) + xmlFreeDoc(static_cast(doc_)); +} + +std::optional RcDocument::load_file(const std::string &path) { + xmlDocPtr doc = xmlReadFile(path.c_str(), nullptr, XML_PARSE_RECOVER); + if (doc == nullptr || xmlDocGetRootElement(doc) == nullptr) { + if (doc != nullptr) + xmlFreeDoc(doc); + return std::nullopt; + } + return RcDocument(static_cast(doc)); +} + +std::optional RcDocument::from_string(const std::string &xml) { + xmlDocPtr doc = xmlReadMemory(xml.c_str(), static_cast(xml.size()), + "rc.xml", nullptr, XML_PARSE_RECOVER); + if (doc == nullptr || xmlDocGetRootElement(doc) == nullptr) { + if (doc != nullptr) + xmlFreeDoc(doc); + return std::nullopt; + } + return RcDocument(static_cast(doc)); +} + +WayboxSettings RcDocument::read() const { + WayboxSettings s; + xmlNode *root = xmlDocGetRootElement(static_cast(doc_)); + if (root == nullptr) + return s; + + s.theme_name = node_text(find_path(root, {"theme", "name"})); + s.placement_policy = node_text(find_path(root, {"placement", "policy"})); + s.margin_top = to_int(node_text(find_path(root, {"margins", "top"}))); + s.margin_bottom = to_int(node_text(find_path(root, {"margins", "bottom"}))); + s.margin_left = to_int(node_text(find_path(root, {"margins", "left"}))); + s.margin_right = to_int(node_text(find_path(root, {"margins", "right"}))); + + xmlNode *menu = find_path(root, {"waybox", "menu"}); + s.menu_source = attr_value(menu, "source"); + s.menu_submenu_open = attr_value(menu, "submenuOpen"); + s.menu_hover_delay = to_int(attr_value(menu, "hoverDelay")); + s.menu_wrap = to_bool(attr_value(menu, "wrap")); + s.menu_icons = to_bool(attr_value(menu, "icons")); + + xmlNode *sw = find_path(root, {"waybox", "switcher"}); + s.switcher_order = attr_value(sw, "order"); + s.switcher_osd = to_bool(attr_value(sw, "osd")); + s.switcher_wrap = to_bool(attr_value(sw, "wrap")); + + xmlNode *tb = find_path(root, {"waybox", "titlebar"}); + s.titlebar_pad_y = to_int(attr_value(tb, "paddingY")); + s.titlebar_button_size = to_int(attr_value(tb, "buttonSize")); + s.titlebar_resize_grab = to_int(attr_value(tb, "resizeGrab")); + return s; +} + +namespace { + +/* Set or remove an element's text, creating the path on set. */ +void put_text(xmlNode *root, const std::vector &path, + const std::optional &value) { + if (value) { + xmlNode *node = ensure_path(root, path); + if (node != nullptr) + xmlNodeSetContent(node, xc(value->c_str())); + } else if (xmlNode *node = find_path(root, path)) { + xmlUnlinkNode(node); + xmlFreeNode(node); + } +} + +/* Set or remove an attribute on an element path, creating the path on set. */ +void put_attr(xmlNode *root, const std::vector &path, + const char *attr, const std::optional &value) { + if (value) { + xmlNode *node = ensure_path(root, path); + if (node != nullptr) + xmlSetProp(node, xc(attr), xc(value->c_str())); + } else if (xmlNode *node = find_path(root, path)) { + xmlUnsetProp(node, xc(attr)); + } +} + +std::optional int_str(const std::optional &v) { + if (!v) + return std::nullopt; + return std::to_string(*v); +} + +std::optional bool_opt_str(const std::optional &v) { + if (!v) + return std::nullopt; + return std::string(bool_str(*v)); +} + +} // namespace + +void RcDocument::apply(const WayboxSettings &s) { + xmlNode *root = xmlDocGetRootElement(static_cast(doc_)); + if (root == nullptr) + return; + + put_text(root, {"theme", "name"}, s.theme_name); + put_text(root, {"placement", "policy"}, s.placement_policy); + put_text(root, {"margins", "top"}, int_str(s.margin_top)); + put_text(root, {"margins", "bottom"}, int_str(s.margin_bottom)); + put_text(root, {"margins", "left"}, int_str(s.margin_left)); + put_text(root, {"margins", "right"}, int_str(s.margin_right)); + + put_attr(root, {"waybox", "menu"}, "source", s.menu_source); + put_attr(root, {"waybox", "menu"}, "submenuOpen", s.menu_submenu_open); + put_attr(root, {"waybox", "menu"}, "hoverDelay", int_str(s.menu_hover_delay)); + put_attr(root, {"waybox", "menu"}, "wrap", bool_opt_str(s.menu_wrap)); + put_attr(root, {"waybox", "menu"}, "icons", bool_opt_str(s.menu_icons)); + + put_attr(root, {"waybox", "switcher"}, "order", s.switcher_order); + put_attr(root, {"waybox", "switcher"}, "osd", bool_opt_str(s.switcher_osd)); + put_attr(root, {"waybox", "switcher"}, "wrap", bool_opt_str(s.switcher_wrap)); + + put_attr(root, {"waybox", "titlebar"}, "paddingY", int_str(s.titlebar_pad_y)); + put_attr(root, {"waybox", "titlebar"}, "buttonSize", + int_str(s.titlebar_button_size)); + put_attr(root, {"waybox", "titlebar"}, "resizeGrab", + int_str(s.titlebar_resize_grab)); +} + +std::string RcDocument::to_string() const { + xmlChar *buf = nullptr; + int size = 0; + xmlDocDumpFormatMemory(static_cast(doc_), &buf, &size, 0); + std::string out; + if (buf != nullptr) { + out.assign(reinterpret_cast(buf), + static_cast(size)); + xmlFree(buf); + } + return out; +} + +bool RcDocument::save_file(const std::string &path) const { + return xmlSaveFormatFileEnc(path.c_str(), static_cast(doc_), + "UTF-8", 0) != -1; +} + +std::vector installed_theme_names() { + namespace fs = std::filesystem; + auto env = [](const char *k) -> std::string { + const char *v = std::getenv(k); + return v ? v : ""; + }; + /* The themerc search bases, mirroring themerc_search_paths(): use a probe + * name and strip the "//openbox-3/themerc" tail to recover each base + * directory, then enumerate the real theme directories under it. */ + std::vector probes = themerc_search_paths( + "\x01", env("HOME"), env("XDG_DATA_HOME"), env("XDG_DATA_DIRS")); + std::vector names; + for (const std::string &probe : probes) { + fs::path p(probe); + /* probe = base//openbox-3/themerc -> base is three parents up. */ + fs::path base = p.parent_path().parent_path().parent_path(); + std::error_code ec; + if (!fs::is_directory(base, ec)) + continue; + for (const fs::directory_entry &e : fs::directory_iterator(base, ec)) { + if (!e.is_directory()) + continue; + fs::path themerc = e.path() / "openbox-3" / "themerc"; + if (fs::exists(themerc, ec)) + names.push_back(e.path().filename().string()); + } + } + std::sort(names.begin(), names.end()); + names.erase(std::unique(names.begin(), names.end()), names.end()); + return names; +} + +} // namespace wb diff --git a/wbconf/main.cpp b/wbconf/main.cpp new file mode 100644 index 0000000..f4ea6e6 --- /dev/null +++ b/wbconf/main.cpp @@ -0,0 +1,433 @@ +/* + * wbconf — a small GTK4 settings GUI for waybox, modelled after Openbox's + * obconf. It edits the user's rc.xml in place (preserving keybindings, + * application rules and comments) through wb::RcDocument, then asks a running + * waybox to reload (SIGUSR2). Only the settings the compositor actually honours + * are exposed, grouped into obconf-style tabs, plus the waybox extensions + * (titlebar / menu / switcher). + * + * All user-visible strings are wrapped in _() for translation, consistent with + * the rest of the project's gettext catalogue (text domain "waybox"). + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "waybox/wbconf.hpp" + +#ifndef GETTEXT_PACKAGE +#define GETTEXT_PACKAGE "waybox" +#endif +#define _(s) gettext(s) + +namespace { + +namespace fs = std::filesystem; + +/* A blank rc.xml used only when the user has no config and none is installed. */ +const char *kSkeletonRc = + "\n" + "\n" + "\n"; + +std::string home_dir() { + if (const char *h = getenv("HOME"); h && h[0]) + return h; + return {}; +} + +/* Where wbconf writes: $XDG_CONFIG_HOME/waybox/rc.xml, else ~/.config/... */ +std::string user_rc_path() { + if (const char *xdg = getenv("XDG_CONFIG_HOME"); xdg && xdg[0]) + return (fs::path(xdg) / "waybox" / "rc.xml").string(); + std::string h = home_dir(); + if (!h.empty()) + return (fs::path(h) / ".config" / "waybox" / "rc.xml").string(); + return {}; +} + +/* The system default, used to seed a brand-new user config. */ +std::string system_rc_path() { +#ifdef WB_SYSCONFDIR + return (fs::path(WB_SYSCONFDIR) / "xdg" / "waybox" / "rc.xml").string(); +#else + return {}; +#endif +} + +/* Load the document to edit: the user's file if present, else the system + * default (so the user inherits its settings), else a blank skeleton. */ +wb::RcDocument load_document() { + std::error_code ec; + std::string up = user_rc_path(); + if (!up.empty() && fs::exists(up, ec)) + if (auto d = wb::RcDocument::load_file(up)) + return std::move(*d); + std::string sp = system_rc_path(); + if (!sp.empty() && fs::exists(sp, ec)) + if (auto d = wb::RcDocument::load_file(sp)) + return std::move(*d); + return *wb::RcDocument::from_string(kSkeletonRc); +} + +/* Ask every waybox process owned by this user to reconfigure (SIGUSR2), the + * same signal Openbox uses. Returns the number signalled. */ +int signal_waybox_reload() { + int signalled = 0; + uid_t me = getuid(); + DIR *proc = opendir("/proc"); + if (proc == nullptr) + return 0; + struct dirent *e; + while ((e = readdir(proc)) != nullptr) { + char *end = nullptr; + long pid = strtol(e->d_name, &end, 10); + if (end == e->d_name || *end != '\0' || pid <= 0) + continue; + std::string comm_path = std::string("/proc/") + e->d_name + "/comm"; + std::ifstream comm(comm_path); + std::string name; + if (!std::getline(comm, name) || name != "waybox") + continue; + struct stat st {}; + std::string dir = std::string("/proc/") + e->d_name; + if (stat(dir.c_str(), &st) != 0 || st.st_uid != me) + continue; + if (kill(static_cast(pid), SIGUSR2) == 0) + ++signalled; + } + closedir(proc); + return signalled; +} + +/* ---- Widget set, bound to a WayboxSettings on save ------------------- */ + +struct Ui { + GtkWindow *window = nullptr; + GtkLabel *status = nullptr; + + /* Appearance */ + GtkDropDown *theme = nullptr; + std::vector theme_names; /* parallel to the drop-down model */ + GtkSpinButton *pad_y = nullptr; + GtkSpinButton *button_size = nullptr; + GtkSpinButton *resize_grab = nullptr; + + /* Windows */ + GtkDropDown *placement = nullptr; /* Smart / Center / UnderMouse */ + + /* Margins */ + GtkSpinButton *margin_top = nullptr; + GtkSpinButton *margin_bottom = nullptr; + GtkSpinButton *margin_left = nullptr; + GtkSpinButton *margin_right = nullptr; + + /* Menu */ + GtkEntry *menu_source = nullptr; + GtkDropDown *submenu_open = nullptr; /* hover / click */ + GtkSpinButton *hover_delay = nullptr; + GtkSwitch *menu_wrap = nullptr; + GtkSwitch *menu_icons = nullptr; + + /* Switcher */ + GtkDropDown *switcher_order = nullptr; /* mru / stacking / spatial */ + GtkSwitch *switcher_osd = nullptr; + GtkSwitch *switcher_wrap = nullptr; +}; + +const char *const kPlacement[] = {"Smart", "Center", "UnderMouse", nullptr}; +const char *const kSubmenu[] = {"hover", "click", nullptr}; +const char *const kOrder[] = {"mru", "stacking", "spatial", nullptr}; + +int index_of(const char *const *list, const std::string &value, int fallback) { + for (int i = 0; list[i] != nullptr; ++i) + if (value == list[i]) + return i; + return fallback; +} + +/* ---- Widget builders -------------------------------------------------- */ + +GtkWidget *make_grid() { + GtkWidget *grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 8); + gtk_grid_set_column_spacing(GTK_GRID(grid), 12); + gtk_widget_set_margin_top(grid, 12); + gtk_widget_set_margin_bottom(grid, 12); + gtk_widget_set_margin_start(grid, 12); + gtk_widget_set_margin_end(grid, 12); + return grid; +} + +void grid_label(GtkWidget *grid, int row, const char *text) { + GtkWidget *label = gtk_label_new(text); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1); +} + +GtkWidget *grid_widget(GtkWidget *grid, int row, GtkWidget *w) { + gtk_widget_set_halign(w, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), w, 1, row, 1, 1); + return w; +} + +GtkSpinButton *add_spin(GtkWidget *grid, int row, const char *label, + int min, int max) { + grid_label(grid, row, label); + GtkWidget *spin = gtk_spin_button_new_with_range(min, max, 1); + grid_widget(grid, row, spin); + return GTK_SPIN_BUTTON(spin); +} + +GtkSwitch *add_switch(GtkWidget *grid, int row, const char *label) { + grid_label(grid, row, label); + GtkWidget *sw = gtk_switch_new(); + gtk_widget_set_halign(sw, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), sw, 1, row, 1, 1); + return GTK_SWITCH(sw); +} + +GtkDropDown *add_dropdown(GtkWidget *grid, int row, const char *label, + const char *const *items) { + grid_label(grid, row, label); + GtkWidget *dd = gtk_drop_down_new_from_strings(items); + grid_widget(grid, row, dd); + return GTK_DROP_DOWN(dd); +} + +/* ---- Read settings into the widgets ---------------------------------- */ + +void populate(Ui *ui, const wb::WayboxSettings &s) { + /* Theme drop-down: installed themes, with the current one guaranteed + * present and selected. */ + ui->theme_names = wb::installed_theme_names(); + std::string current = s.theme_name.value_or(""); + if (!current.empty() && + std::find(ui->theme_names.begin(), ui->theme_names.end(), current) == + ui->theme_names.end()) + ui->theme_names.insert(ui->theme_names.begin(), current); + if (ui->theme_names.empty()) + ui->theme_names.push_back(current.empty() ? "Clearlooks" : current); + + std::vector items; + for (const std::string &n : ui->theme_names) + items.push_back(n.c_str()); + items.push_back(nullptr); + GtkStringList *model = gtk_string_list_new(items.data()); + gtk_drop_down_set_model(ui->theme, G_LIST_MODEL(model)); + g_object_unref(model); + guint sel = 0; + for (guint i = 0; i < ui->theme_names.size(); ++i) + if (ui->theme_names[i] == current) { sel = i; break; } + gtk_drop_down_set_selected(ui->theme, sel); + + gtk_spin_button_set_value(ui->pad_y, s.titlebar_pad_y.value_or(2)); + gtk_spin_button_set_value(ui->button_size, s.titlebar_button_size.value_or(0)); + gtk_spin_button_set_value(ui->resize_grab, s.titlebar_resize_grab.value_or(8)); + + gtk_drop_down_set_selected(ui->placement, + index_of(kPlacement, s.placement_policy.value_or("Smart"), 0)); + + gtk_spin_button_set_value(ui->margin_top, s.margin_top.value_or(0)); + gtk_spin_button_set_value(ui->margin_bottom, s.margin_bottom.value_or(0)); + gtk_spin_button_set_value(ui->margin_left, s.margin_left.value_or(0)); + gtk_spin_button_set_value(ui->margin_right, s.margin_right.value_or(0)); + + gtk_editable_set_text(GTK_EDITABLE(ui->menu_source), + s.menu_source.value_or("builtin").c_str()); + gtk_drop_down_set_selected(ui->submenu_open, + index_of(kSubmenu, s.menu_submenu_open.value_or("hover"), 0)); + gtk_spin_button_set_value(ui->hover_delay, s.menu_hover_delay.value_or(100)); + gtk_switch_set_active(ui->menu_wrap, s.menu_wrap.value_or(true)); + gtk_switch_set_active(ui->menu_icons, s.menu_icons.value_or(true)); + + gtk_drop_down_set_selected(ui->switcher_order, + index_of(kOrder, s.switcher_order.value_or("mru"), 0)); + gtk_switch_set_active(ui->switcher_osd, s.switcher_osd.value_or(true)); + gtk_switch_set_active(ui->switcher_wrap, s.switcher_wrap.value_or(true)); +} + +/* ---- Collect widget state back into a WayboxSettings ----------------- */ + +wb::WayboxSettings collect(Ui *ui) { + wb::WayboxSettings s; + guint t = gtk_drop_down_get_selected(ui->theme); + if (t != GTK_INVALID_LIST_POSITION && t < ui->theme_names.size()) + s.theme_name = ui->theme_names[t]; + s.titlebar_pad_y = gtk_spin_button_get_value_as_int(ui->pad_y); + s.titlebar_button_size = gtk_spin_button_get_value_as_int(ui->button_size); + s.titlebar_resize_grab = gtk_spin_button_get_value_as_int(ui->resize_grab); + + s.placement_policy = kPlacement[gtk_drop_down_get_selected(ui->placement)]; + + s.margin_top = gtk_spin_button_get_value_as_int(ui->margin_top); + s.margin_bottom = gtk_spin_button_get_value_as_int(ui->margin_bottom); + s.margin_left = gtk_spin_button_get_value_as_int(ui->margin_left); + s.margin_right = gtk_spin_button_get_value_as_int(ui->margin_right); + + s.menu_source = gtk_editable_get_text(GTK_EDITABLE(ui->menu_source)); + s.menu_submenu_open = kSubmenu[gtk_drop_down_get_selected(ui->submenu_open)]; + s.menu_hover_delay = gtk_spin_button_get_value_as_int(ui->hover_delay); + s.menu_wrap = gtk_switch_get_active(ui->menu_wrap); + s.menu_icons = gtk_switch_get_active(ui->menu_icons); + + s.switcher_order = kOrder[gtk_drop_down_get_selected(ui->switcher_order)]; + s.switcher_osd = gtk_switch_get_active(ui->switcher_osd); + s.switcher_wrap = gtk_switch_get_active(ui->switcher_wrap); + return s; +} + +void set_status(Ui *ui, const std::string &text) { + if (ui->status != nullptr) + gtk_label_set_text(ui->status, text.c_str()); +} + +void on_save(GtkButton *, gpointer data) { + auto *ui = static_cast(data); + std::string path = user_rc_path(); + if (path.empty()) { + set_status(ui, _("Could not determine the rc.xml path.")); + return; + } + std::error_code ec; + fs::create_directories(fs::path(path).parent_path(), ec); + + wb::RcDocument doc = load_document(); + doc.apply(collect(ui)); + if (!doc.save_file(path)) { + set_status(ui, std::string(_("Failed to write ")) + path); + return; + } + int n = signal_waybox_reload(); + if (n > 0) + set_status(ui, _("Saved and reloaded waybox.")); + else + set_status(ui, std::string(_("Saved to ")) + path + " " + + _("(no running waybox to reload).")); +} + +void on_close(GtkButton *, gpointer data) { + auto *ui = static_cast(data); + gtk_window_close(ui->window); +} + +void build_window(GtkApplication *app, Ui *ui) { + ui->window = GTK_WINDOW(gtk_application_window_new(app)); + gtk_window_set_title(ui->window, _("Waybox Configuration")); + gtk_window_set_default_size(ui->window, 460, 420); + + GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_window_set_child(ui->window, vbox); + + GtkWidget *notebook = gtk_notebook_new(); + gtk_widget_set_vexpand(notebook, TRUE); + gtk_box_append(GTK_BOX(vbox), notebook); + + /* Appearance */ + GtkWidget *appearance = make_grid(); + const char *const empty[] = {"", nullptr}; + ui->theme = add_dropdown(appearance, 0, _("Theme:"), empty); + ui->pad_y = add_spin(appearance, 1, _("Titlebar padding (px):"), 0, 16); + ui->button_size = add_spin(appearance, 2, + _("Button size (px, 0 = auto):"), 0, 64); + ui->resize_grab = add_spin(appearance, 3, + _("Resize grab margin (px):"), 0, 32); + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), appearance, + gtk_label_new(_("Appearance"))); + + /* Windows */ + GtkWidget *windows = make_grid(); + ui->placement = add_dropdown(windows, 0, _("Placement:"), kPlacement); + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), windows, + gtk_label_new(_("Windows"))); + + /* Margins */ + GtkWidget *margins = make_grid(); + ui->margin_top = add_spin(margins, 0, _("Top:"), 0, 1024); + ui->margin_bottom = add_spin(margins, 1, _("Bottom:"), 0, 1024); + ui->margin_left = add_spin(margins, 2, _("Left:"), 0, 1024); + ui->margin_right = add_spin(margins, 3, _("Right:"), 0, 1024); + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), margins, + gtk_label_new(_("Margins"))); + + /* Menu */ + GtkWidget *menu = make_grid(); + grid_label(menu, 0, _("Source (builtin or command):")); + ui->menu_source = GTK_ENTRY(grid_widget(menu, 0, gtk_entry_new())); + ui->submenu_open = add_dropdown(menu, 1, _("Submenu opens on:"), kSubmenu); + ui->hover_delay = add_spin(menu, 2, _("Submenu hover delay (ms):"), 0, 2000); + ui->menu_wrap = add_switch(menu, 3, _("Wrap selection:")); + ui->menu_icons = add_switch(menu, 4, _("Show icons:")); + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), menu, + gtk_label_new(_("Menu"))); + + /* Switcher */ + GtkWidget *switcher = make_grid(); + ui->switcher_order = add_dropdown(switcher, 0, _("Order:"), kOrder); + ui->switcher_osd = add_switch(switcher, 1, _("Show OSD:")); + ui->switcher_wrap = add_switch(switcher, 2, _("Wrap selection:")); + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), switcher, + gtk_label_new(_("Switcher"))); + + /* Action row + status line. */ + GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_margin_start(actions, 12); + gtk_widget_set_margin_end(actions, 12); + gtk_widget_set_margin_bottom(actions, 12); + + GtkWidget *status = gtk_label_new(""); + gtk_widget_set_hexpand(status, TRUE); + gtk_widget_set_halign(status, GTK_ALIGN_START); + ui->status = GTK_LABEL(status); + gtk_box_append(GTK_BOX(actions), status); + + GtkWidget *close = gtk_button_new_with_label(_("Close")); + g_signal_connect(close, "clicked", G_CALLBACK(on_close), ui); + gtk_box_append(GTK_BOX(actions), close); + + GtkWidget *save = gtk_button_new_with_label(_("Save & Apply")); + gtk_widget_add_css_class(save, "suggested-action"); + g_signal_connect(save, "clicked", G_CALLBACK(on_save), ui); + gtk_box_append(GTK_BOX(actions), save); + + gtk_box_append(GTK_BOX(vbox), actions); + + populate(ui, load_document().read()); + gtk_window_present(ui->window); +} + +void on_activate(GtkApplication *app, gpointer data) { + build_window(app, static_cast(data)); +} + +} // namespace + +int main(int argc, char **argv) { + setlocale(LC_ALL, ""); +#ifdef LOCALEDIR + bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR); + bind_textdomain_codeset(GETTEXT_PACKAGE, "UTF-8"); +#endif + textdomain(GETTEXT_PACKAGE); + + Ui ui; + GtkApplication *app = gtk_application_new("org.waybox.wbconf", + G_APPLICATION_DEFAULT_FLAGS); + g_signal_connect(app, "activate", G_CALLBACK(on_activate), &ui); + int status = g_application_run(G_APPLICATION(app), argc, argv); + g_object_unref(app); + return status; +} diff --git a/wbconf/meson.build b/wbconf/meson.build new file mode 100644 index 0000000..5df9442 --- /dev/null +++ b/wbconf/meson.build @@ -0,0 +1,24 @@ +# wbconf: the GTK4 settings GUI for waybox. Links the rc.xml model (wbconf.cpp) +# and the pure theme helpers; pulls in GTK4 and libxml2. Built only when GTK4 is +# present (see the top-level meson.build). + +wbconf_args = [ + '-DGETTEXT_PACKAGE="' + meson.project_name().to_lower() + '"', + '-DLOCALEDIR="' + get_option('prefix') / get_option('localedir') + '"', + '-DUSE_NLS=1', +] + +executable( + 'wbconf', + ['main.cpp', files('../waybox/wbconf.cpp'), theme_src], + include_directories: [inc_dir], + dependencies: [gtk4, libxml2], + cpp_args: wbconf_args, + install: true, +) + +# Desktop entry so wbconf shows up in application menus / launchers. +install_data( + 'wbconf.desktop', + install_dir: get_option('datadir') / 'applications', +) diff --git a/wbconf/wbconf.desktop b/wbconf/wbconf.desktop new file mode 100644 index 0000000..b890417 --- /dev/null +++ b/wbconf/wbconf.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=Waybox Configuration +GenericName=Window Manager Settings +Comment=Tweak the waybox compositor: theme, titlebar, menu and task switcher +Exec=wbconf +Icon=preferences-desktop +Terminal=false +Categories=Settings;DesktopSettings;GTK; +Keywords=waybox;openbox;compositor;wayland;theme;configuration; From 369da4d0beadd97d838622f6bf4aad9726ce3246 Mon Sep 17 00:00:00 2001 From: ebenali <54529923+ebenali@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:43:45 +0000 Subject: [PATCH 2/2] feat(wbconf): obconf-style visual layout + root-menu entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle the wbconf GUI to match obconf's visual idiom and wire it into the default menu like Openbox does with obconf: - Each tab is now a page of bold section headers ("…") over 12px-indented content, matching obconf's layout. - The Theme tab is a scrolling, browse-selection list of installed themes (obconf-style) instead of a drop-down, with the active theme selected. - Tabs renamed to obconf's vocabulary: Theme, Appearance ("Window Titles"), Windows ("Placing Windows"), Margins ("Reserved Screen Margins"), Menu ("Root Menu"), Switcher ("Task Switcher"). - data/menu.xml: add a separator and a "Configure" entry that launches wbconf in the root menu, mirroring Openbox shipping ObConf in its menu. (Separators were already supported by the menu parser/renderer.) All strings remain wrapped in _() for translation. Verified: full suite 27 green (g++/ASan); wbconf builds and maps under real Wayland with the new layout and no GTK/ASan errors; waybox parses the updated menu (separator + Configure) without error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- data/menu.xml | 8 ++ wbconf/main.cpp | 236 +++++++++++++++++++++++++++++++----------------- 2 files changed, 163 insertions(+), 81 deletions(-) diff --git a/data/menu.xml b/data/menu.xml index 09564b8..f369438 100644 --- a/data/menu.xml +++ b/data/menu.xml @@ -37,6 +37,14 @@ + + + + + wbconf + + + diff --git a/wbconf/main.cpp b/wbconf/main.cpp index f4ea6e6..4e80a90 100644 --- a/wbconf/main.cpp +++ b/wbconf/main.cpp @@ -1,9 +1,10 @@ /* * wbconf — a small GTK4 settings GUI for waybox, modelled after Openbox's - * obconf. It edits the user's rc.xml in place (preserving keybindings, - * application rules and comments) through wb::RcDocument, then asks a running - * waybox to reload (SIGUSR2). Only the settings the compositor actually honours - * are exposed, grouped into obconf-style tabs, plus the waybox extensions + * obconf (in scope and in visual layout: bold section headers over indented + * content, a scrolling theme list, obconf-style tabs). It edits the user's + * rc.xml in place (preserving keybindings, application rules and comments) + * through wb::RcDocument, then asks a running waybox to reload (SIGUSR2). Only + * the settings the compositor honours are exposed, plus the waybox extensions * (titlebar / menu / switcher). * * All user-visible strings are wrapped in _() for translation, consistent with @@ -120,8 +121,8 @@ struct Ui { GtkLabel *status = nullptr; /* Appearance */ - GtkDropDown *theme = nullptr; - std::vector theme_names; /* parallel to the drop-down model */ + GtkListBox *theme_list = nullptr; /* obconf-style scrolling list */ + std::vector theme_names; /* parallel to the list rows */ GtkSpinButton *pad_y = nullptr; GtkSpinButton *button_size = nullptr; GtkSpinButton *resize_grab = nullptr; @@ -159,16 +160,42 @@ int index_of(const char *const *list, const std::string &value, int fallback) { return fallback; } -/* ---- Widget builders -------------------------------------------------- */ +/* ---- obconf-style layout helpers ------------------------------------- */ -GtkWidget *make_grid() { +/* A notebook page: a vertical box with obconf's outer padding. */ +GtkWidget *make_page() { + GtkWidget *page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_margin_top(page, 12); + gtk_widget_set_margin_bottom(page, 12); + gtk_widget_set_margin_start(page, 12); + gtk_widget_set_margin_end(page, 12); + return page; +} + +/* A bold section header followed by an indented content box (obconf's idiom of + * "Title" over a 12px-indented VBox). Returns the + * content box to which callers append their controls. */ +GtkWidget *section(GtkWidget *page, const char *title) { + GtkWidget *header = gtk_label_new(nullptr); + char *markup = g_markup_printf_escaped("%s", + title); + gtk_label_set_markup(GTK_LABEL(header), markup); + g_free(markup); + gtk_widget_set_halign(header, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), header); + + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(content, 12); + gtk_box_append(GTK_BOX(page), content); + return content; +} + +/* A grid for label/control rows, appended to a section's content box. */ +GtkWidget *section_grid(GtkWidget *content) { GtkWidget *grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 8); gtk_grid_set_column_spacing(GTK_GRID(grid), 12); - gtk_widget_set_margin_top(grid, 12); - gtk_widget_set_margin_bottom(grid, 12); - gtk_widget_set_margin_start(grid, 12); - gtk_widget_set_margin_end(grid, 12); + gtk_box_append(GTK_BOX(content), grid); return grid; } @@ -178,7 +205,7 @@ void grid_label(GtkWidget *grid, int row, const char *text) { gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1); } -GtkWidget *grid_widget(GtkWidget *grid, int row, GtkWidget *w) { +GtkWidget *grid_control(GtkWidget *grid, int row, GtkWidget *w) { gtk_widget_set_halign(w, GTK_ALIGN_START); gtk_grid_attach(GTK_GRID(grid), w, 1, row, 1, 1); return w; @@ -188,7 +215,7 @@ GtkSpinButton *add_spin(GtkWidget *grid, int row, const char *label, int min, int max) { grid_label(grid, row, label); GtkWidget *spin = gtk_spin_button_new_with_range(min, max, 1); - grid_widget(grid, row, spin); + grid_control(grid, row, spin); return GTK_SPIN_BUTTON(spin); } @@ -204,35 +231,43 @@ GtkDropDown *add_dropdown(GtkWidget *grid, int row, const char *label, const char *const *items) { grid_label(grid, row, label); GtkWidget *dd = gtk_drop_down_new_from_strings(items); - grid_widget(grid, row, dd); + grid_control(grid, row, dd); return GTK_DROP_DOWN(dd); } /* ---- Read settings into the widgets ---------------------------------- */ void populate(Ui *ui, const wb::WayboxSettings &s) { - /* Theme drop-down: installed themes, with the current one guaranteed - * present and selected. */ + /* Theme list: every installed theme, current one guaranteed present and + * selected (obconf shows the active theme highlighted in the list). */ ui->theme_names = wb::installed_theme_names(); std::string current = s.theme_name.value_or(""); if (!current.empty() && std::find(ui->theme_names.begin(), ui->theme_names.end(), current) == ui->theme_names.end()) ui->theme_names.insert(ui->theme_names.begin(), current); - if (ui->theme_names.empty()) - ui->theme_names.push_back(current.empty() ? "Clearlooks" : current); - - std::vector items; - for (const std::string &n : ui->theme_names) - items.push_back(n.c_str()); - items.push_back(nullptr); - GtkStringList *model = gtk_string_list_new(items.data()); - gtk_drop_down_set_model(ui->theme, G_LIST_MODEL(model)); - g_object_unref(model); - guint sel = 0; - for (guint i = 0; i < ui->theme_names.size(); ++i) - if (ui->theme_names[i] == current) { sel = i; break; } - gtk_drop_down_set_selected(ui->theme, sel); + if (ui->theme_names.empty() && !current.empty()) + ui->theme_names.push_back(current); + + /* Clear any existing rows, then repopulate. */ + GtkListBoxRow *row; + while ((row = gtk_list_box_get_row_at_index(ui->theme_list, 0)) != nullptr) + gtk_list_box_remove(ui->theme_list, GTK_WIDGET(row)); + int sel = -1; + for (size_t i = 0; i < ui->theme_names.size(); ++i) { + GtkWidget *label = gtk_label_new(ui->theme_names[i].c_str()); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_widget_set_margin_top(label, 4); + gtk_widget_set_margin_bottom(label, 4); + gtk_widget_set_margin_start(label, 6); + gtk_widget_set_margin_end(label, 6); + gtk_list_box_append(ui->theme_list, label); + if (ui->theme_names[i] == current) + sel = static_cast(i); + } + if (sel >= 0) + gtk_list_box_select_row(ui->theme_list, + gtk_list_box_get_row_at_index(ui->theme_list, sel)); gtk_spin_button_set_value(ui->pad_y, s.titlebar_pad_y.value_or(2)); gtk_spin_button_set_value(ui->button_size, s.titlebar_button_size.value_or(0)); @@ -264,9 +299,11 @@ void populate(Ui *ui, const wb::WayboxSettings &s) { wb::WayboxSettings collect(Ui *ui) { wb::WayboxSettings s; - guint t = gtk_drop_down_get_selected(ui->theme); - if (t != GTK_INVALID_LIST_POSITION && t < ui->theme_names.size()) - s.theme_name = ui->theme_names[t]; + if (GtkListBoxRow *r = gtk_list_box_get_selected_row(ui->theme_list)) { + int idx = gtk_list_box_row_get_index(r); + if (idx >= 0 && idx < static_cast(ui->theme_names.size())) + s.theme_name = ui->theme_names[idx]; + } s.titlebar_pad_y = gtk_spin_button_get_value_as_int(ui->pad_y); s.titlebar_button_size = gtk_spin_button_get_value_as_int(ui->button_size); s.titlebar_resize_grab = gtk_spin_button_get_value_as_int(ui->resize_grab); @@ -324,10 +361,86 @@ void on_close(GtkButton *, gpointer data) { gtk_window_close(ui->window); } +/* ---- obconf-style tab pages ------------------------------------------ */ + +GtkWidget *build_theme_page(Ui *ui) { + GtkWidget *page = make_page(); + GtkWidget *content = section(page, _("Theme")); + + GtkWidget *scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(scroller, TRUE); + + GtkWidget *list = gtk_list_box_new(); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_BROWSE); + ui->theme_list = GTK_LIST_BOX(list); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), list); + gtk_box_append(GTK_BOX(content), scroller); + return page; +} + +GtkWidget *build_appearance_page(Ui *ui) { + GtkWidget *page = make_page(); + GtkWidget *content = section(page, _("Window Titles")); + GtkWidget *grid = section_grid(content); + ui->pad_y = add_spin(grid, 0, _("Titlebar padding (px):"), 0, 16); + ui->button_size = add_spin(grid, 1, _("Button size (px, 0 = auto):"), 0, 64); + ui->resize_grab = add_spin(grid, 2, _("Resize grab margin (px):"), 0, 32); + return page; +} + +GtkWidget *build_windows_page(Ui *ui) { + GtkWidget *page = make_page(); + GtkWidget *content = section(page, _("Placing Windows")); + GtkWidget *grid = section_grid(content); + ui->placement = add_dropdown(grid, 0, _("Prefer to place windows:"), + kPlacement); + return page; +} + +GtkWidget *build_margins_page(Ui *ui) { + GtkWidget *page = make_page(); + GtkWidget *content = section(page, _("Reserved Screen Margins")); + GtkWidget *grid = section_grid(content); + ui->margin_top = add_spin(grid, 0, _("Top:"), 0, 1024); + ui->margin_bottom = add_spin(grid, 1, _("Bottom:"), 0, 1024); + ui->margin_left = add_spin(grid, 2, _("Left:"), 0, 1024); + ui->margin_right = add_spin(grid, 3, _("Right:"), 0, 1024); + return page; +} + +GtkWidget *build_menu_page(Ui *ui) { + GtkWidget *page = make_page(); + GtkWidget *content = section(page, _("Root Menu")); + GtkWidget *grid = section_grid(content); + grid_label(grid, 0, _("Source (builtin or command):")); + ui->menu_source = GTK_ENTRY(grid_control(grid, 0, gtk_entry_new())); + ui->submenu_open = add_dropdown(grid, 1, _("Submenu opens on:"), kSubmenu); + ui->hover_delay = add_spin(grid, 2, _("Submenu hover delay (ms):"), 0, 2000); + ui->menu_wrap = add_switch(grid, 3, _("Wrap selection:")); + ui->menu_icons = add_switch(grid, 4, _("Show icons:")); + return page; +} + +GtkWidget *build_switcher_page(Ui *ui) { + GtkWidget *page = make_page(); + GtkWidget *content = section(page, _("Task Switcher")); + GtkWidget *grid = section_grid(content); + ui->switcher_order = add_dropdown(grid, 0, _("Order:"), kOrder); + ui->switcher_osd = add_switch(grid, 1, _("Show on-screen display:")); + ui->switcher_wrap = add_switch(grid, 2, _("Wrap selection:")); + return page; +} + +void add_tab(GtkWidget *notebook, GtkWidget *page, const char *label) { + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), page, gtk_label_new(label)); +} + void build_window(GtkApplication *app, Ui *ui) { ui->window = GTK_WINDOW(gtk_application_window_new(app)); - gtk_window_set_title(ui->window, _("Waybox Configuration")); - gtk_window_set_default_size(ui->window, 460, 420); + gtk_window_set_title(ui->window, _("Waybox Configuration Manager")); + gtk_window_set_default_size(ui->window, 500, 460); GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_window_set_child(ui->window, vbox); @@ -336,51 +449,12 @@ void build_window(GtkApplication *app, Ui *ui) { gtk_widget_set_vexpand(notebook, TRUE); gtk_box_append(GTK_BOX(vbox), notebook); - /* Appearance */ - GtkWidget *appearance = make_grid(); - const char *const empty[] = {"", nullptr}; - ui->theme = add_dropdown(appearance, 0, _("Theme:"), empty); - ui->pad_y = add_spin(appearance, 1, _("Titlebar padding (px):"), 0, 16); - ui->button_size = add_spin(appearance, 2, - _("Button size (px, 0 = auto):"), 0, 64); - ui->resize_grab = add_spin(appearance, 3, - _("Resize grab margin (px):"), 0, 32); - gtk_notebook_append_page(GTK_NOTEBOOK(notebook), appearance, - gtk_label_new(_("Appearance"))); - - /* Windows */ - GtkWidget *windows = make_grid(); - ui->placement = add_dropdown(windows, 0, _("Placement:"), kPlacement); - gtk_notebook_append_page(GTK_NOTEBOOK(notebook), windows, - gtk_label_new(_("Windows"))); - - /* Margins */ - GtkWidget *margins = make_grid(); - ui->margin_top = add_spin(margins, 0, _("Top:"), 0, 1024); - ui->margin_bottom = add_spin(margins, 1, _("Bottom:"), 0, 1024); - ui->margin_left = add_spin(margins, 2, _("Left:"), 0, 1024); - ui->margin_right = add_spin(margins, 3, _("Right:"), 0, 1024); - gtk_notebook_append_page(GTK_NOTEBOOK(notebook), margins, - gtk_label_new(_("Margins"))); - - /* Menu */ - GtkWidget *menu = make_grid(); - grid_label(menu, 0, _("Source (builtin or command):")); - ui->menu_source = GTK_ENTRY(grid_widget(menu, 0, gtk_entry_new())); - ui->submenu_open = add_dropdown(menu, 1, _("Submenu opens on:"), kSubmenu); - ui->hover_delay = add_spin(menu, 2, _("Submenu hover delay (ms):"), 0, 2000); - ui->menu_wrap = add_switch(menu, 3, _("Wrap selection:")); - ui->menu_icons = add_switch(menu, 4, _("Show icons:")); - gtk_notebook_append_page(GTK_NOTEBOOK(notebook), menu, - gtk_label_new(_("Menu"))); - - /* Switcher */ - GtkWidget *switcher = make_grid(); - ui->switcher_order = add_dropdown(switcher, 0, _("Order:"), kOrder); - ui->switcher_osd = add_switch(switcher, 1, _("Show OSD:")); - ui->switcher_wrap = add_switch(switcher, 2, _("Wrap selection:")); - gtk_notebook_append_page(GTK_NOTEBOOK(notebook), switcher, - gtk_label_new(_("Switcher"))); + add_tab(notebook, build_theme_page(ui), _("Theme")); + add_tab(notebook, build_appearance_page(ui), _("Appearance")); + add_tab(notebook, build_windows_page(ui), _("Windows")); + add_tab(notebook, build_margins_page(ui), _("Margins")); + add_tab(notebook, build_menu_page(ui), _("Menu")); + add_tab(notebook, build_switcher_page(ui), _("Switcher")); /* Action row + status line. */ GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);