diff --git a/.github/wiki/mapping.json b/.github/wiki/mapping.json index d17338ed6b..0c114fba52 100644 --- a/.github/wiki/mapping.json +++ b/.github/wiki/mapping.json @@ -35,7 +35,7 @@ "Module:-PulseAudio-Slider": ["waybar-pulseaudio-slider"], "Module:-River": ["waybar-river-tags", "waybar-river-mode", "waybar-river-window", "waybar-river-layout"], "Module:-Sndio": ["waybar-sndio"], - "Module:-Sway": ["waybar-sway-workspaces", "waybar-sway-window", "waybar-sway-mode", "waybar-sway-scratchpad"], + "Module:-Sway": ["waybar-sway-workspaces", "waybar-sway-window", "waybar-sway-mode", "waybar-sway-scratchpad", "waybar-sway-taskbar"], "Module:-Systemd-failed-units": ["waybar-systemd-failed-units"], "Module:-Taskbar": ["waybar-wlr-taskbar"], "Module:-Temperature": ["waybar-temperature"], diff --git a/include/modules/sway/taskbar.hpp b/include/modules/sway/taskbar.hpp new file mode 100644 index 0000000000..a5ce74f8cd --- /dev/null +++ b/include/modules/sway/taskbar.hpp @@ -0,0 +1,208 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AModule.hpp" +#include "bar.hpp" +#include "modules/sway/ipc/client.hpp" +#include "util/icon_loader.hpp" +#include "util/json.hpp" + +namespace waybar::modules::sway { + +// Plain data snapshot describing a single view (window), parsed off the sway +// IPC tree on the Ipc worker thread. Contains no GTK state. +struct TaskInfo { + int64_t id = -1; + std::string title; + // Native Wayland views report an app_id; for XWayland views sway reports the + // X11 instance and class hints instead. app_id holds the app_id (or the + // instance), app_class the class, which is the better icon lookup key. + std::string app_id; + std::string app_class; + bool active = false; + bool fullscreen = false; + bool urgent = false; + std::string workspace; + // Whether this window's workspace is the one currently displayed on its + // output. Always true when "all-workspaces" is false, since the tree walk + // filters non-visible workspaces out in that case. + bool workspace_visible = false; + // Whether this window is on the same output as the bar. Always true when + // "all-outputs" is false, since the tree walk filters other outputs out in + // that case. + bool current_output = false; +}; + +class Taskbar; + +class Task { + public: + Task(const waybar::Bar&, const Json::Value&, Taskbar*, const TaskInfo&); + ~Task(); + + // made public so Taskbar can reorder based on configuration. + Gtk::Button button; + + /* Getter functions */ + int64_t id() const { return id_; } + std::string title() const { return title_; } + std::string app_id() const { return app_id_; } + bool active() const { return active_; } + bool fullscreen() const { return fullscreen_; } + bool urgent() const { return urgent_; } + std::string workspace() const { return workspace_; } + bool current_output() const { return current_output_; } + /* Whether the button is packed into the taskbar box. */ + bool visible() const { return button_visible_; } + /* Whether the button is packed *and* actually rendered: with "active-only" + * the buttons of inactive views stay packed but hidden. */ + bool shown() const { return button_visible_ && button.get_visible(); } + + void showButton(); + void hideButton(); + + /* Update the cached snapshot for this task. Only ever called from the main + * thread (from Taskbar::update()), so it is safe to touch GTK/IconTheme + * here. */ + void setData(const TaskInfo&); + + /* Apply the ignored/squashed decision computed by Taskbar::update() (which + * has the whole-snapshot view needed to keep exactly one instance of a + * squashed group visible) and show or hide the button accordingly. */ + void applyVisibility(bool ignored, bool squashed); + + void update(); + + /* Callbacks for Gtk events */ + bool handleClicked(GdkEventButton*); + void handleDragDataGet(const Glib::RefPtr& context, + Gtk::SelectionData& selection_data, guint info, guint time); + void handleDragDataReceived(const Glib::RefPtr& context, int x, int y, + Gtk::SelectionData selection_data, guint info, guint time); + + private: + std::string stateString(bool shortened = false) const; + /* Expand one user-supplied format string. A malformed format (e.g. an + * unknown {placeholder}) makes fmt throw; this warns once and yields an + * empty string rather than letting the exception escape update(). */ + std::string formatText(const std::string& format, const std::string& title, + const std::string& name, const std::string& app_id); + + const Json::Value& config_; + Taskbar* tbar_; + + int64_t id_; + + Gtk::Box content_; + Gtk::Image icon_; + Gtk::Label text_before_; + Gtk::Label text_after_; + Glib::RefPtr app_info_; + bool button_visible_ = false; + + bool with_icon_ = false; + std::string format_before_; + std::string format_after_; + std::string format_tooltip_; + bool format_warned_ = false; + + bool markup_ = false; + bool active_only_ = false; + int icon_size_ = 16; + + std::string name_; + std::string title_; + std::string app_id_; + std::string app_class_; + /* False until setData() has resolved app_info_/name_ once, so a task whose + * app_id is empty still gets its initial resolution. */ + bool app_info_resolved_ = false; + bool active_ = false; + bool fullscreen_ = false; + bool urgent_ = false; + std::string workspace_; + bool workspace_visible_ = false; + bool current_output_ = false; +}; + +using TaskPtr = std::unique_ptr; + +class Taskbar : public waybar::AModule { + public: + Taskbar(const std::string&, const waybar::Bar&, const Json::Value&); + ~Taskbar() override; + void update() override; + + /* Used by Task to (un)pack and reorder its button. */ + void addButton(Gtk::Button&); + void moveButton(Gtk::Button&, int); + void removeButton(Gtk::Button&); + /* Move the task with con_id `dragged_id` in front of `target_id` and persist + * the resulting order. Unknown ids (e.g. a window that closed mid-drag) are + * a no-op. */ + void reorderTask(int64_t dragged_id, int64_t target_id); + + Ipc& ipc(); + const IconLoader& iconLoader() const; + + private: + void onEvent(const struct Ipc::ipc_response&); + void onCmd(const struct Ipc::ipc_response&); + + bool allOutputs() const; + bool allWorkspaces() const; + void recordUserOrder(); + std::size_t taskAppIdCount(std::string_view app_id) const; + std::size_t taskTitleCount(std::string_view title) const; + void setBarCssClass(const std::string&, bool); + + const waybar::Bar& bar_; + Gtk::Box box_; + std::vector tasks_; + + IconLoader icon_loader_; + std::unordered_set ignore_list_; + std::unordered_set squash_list_; + std::map app_ids_replace_map_; + + bool bar_css_states_ = false; + bool sort_by_app_id_ = false; + bool active_first_ = false; + bool homogeneous_ = false; + bool expand_ = false; + + // Persisted drag-and-drop child order, keyed by sway con_id, applied on top + // of the tree/sort order every update(). Ids of closed windows are kept until + // the next drag rewrites the list; update() simply skips ids it cannot match, + // so this is bounded by how many windows the user has ever dragged. + std::vector user_order_; + + // Snapshot produced by onCmd() on the Ipc worker thread and consumed by + // update() on the main thread. Guarded by mutex_. + std::vector windows_; + std::string current_workspace_; + + util::JsonParser parser_; + std::mutex mutex_; + // Must be declared last: its destructor joins the IPC worker thread, and the + // worker posts to the widgets above, which must therefore outlive it. + Ipc ipc_; +}; + +} // namespace waybar::modules::sway diff --git a/man/waybar-sway-taskbar.5.scd b/man/waybar-sway-taskbar.5.scd new file mode 100644 index 0000000000..950cac1133 --- /dev/null +++ b/man/waybar-sway-taskbar.5.scd @@ -0,0 +1,278 @@ +waybar-sway-taskbar(5) + +# NAME + +waybar - sway taskbar module + +# DESCRIPTION + +The *taskbar* module displays the currently open applications. This version +sources its data from the sway IPC protocol and requires running under sway. + +Its configuration is a superset of the *wlr/taskbar* module's, and with default +settings it lists the same applications, so it can be used as a drop-in +replacement. Reading the IPC tree rather than the *wlr-foreign-toplevel* +protocol additionally makes windows on non-displayed workspaces available; see +*all-workspaces*. + +# CONFIGURATION + +Addressed by *sway/taskbar* + +*all-outputs*: ++ + typeof: bool ++ + default: false ++ + If set to false, only applications on the bar's current output are shown. + Otherwise, applications on all outputs are shown. Also supplies the default + for *all-workspaces*. + +*all-workspaces*: ++ + typeof: bool ++ + default: value of *all-outputs* ++ + If set to false, only applications on the workspace currently displayed on + their output are shown. Otherwise, applications on all workspaces are shown. + When left unset it follows *all-outputs*, which reproduces the behaviour of + the *wlr/taskbar* module. + +*bar-css-states*: ++ + typeof: bool ++ + default: false ++ + If set to true, application state is exposed as CSS classes on the Waybar + window, aggregated over the current workspace. Derived from the sway IPC + tree. See *Bar state style* below. + +*format*: ++ + typeof: string ++ + default: {icon} ++ + The format, how information should be displayed. + +*icon-theme*: ++ + typeof: array|string ++ + The names of the icon-themes that should be used to find an icon. The list will be traversed from left to right. If omitted, the system default will be used. + +*icon-size*: ++ + typeof: integer ++ + default: 16 ++ + The size of the icon. + +*markup*: ++ + typeof: bool ++ + default: false ++ + If set to true, pango markup will be accepted in format and tooltip-format. + +*tooltip*: ++ + typeof: bool ++ + default: true ++ + If set to false no tooltip will be shown. + +*tooltip-format*: ++ + typeof: string ++ + default: {title} ++ + The format, how information in the tooltip should be displayed. + +*active-first*: ++ + typeof: bool ++ + default: false ++ + If set to true, always reorder the tasks in the taskbar so that the + currently active one is first. Otherwise don't reorder. Overridden once the + tasks have been reordered by dragging; see *REORDERING*. + +*active-only*: ++ + typeof: bool ++ + default: false ++ + If set to true, only the currently active application button is shown. + Other applications remain tracked and reappear when activated. + +*sort-by-app-id*: ++ + typeof: bool ++ + default: false ++ + If set to true, group tasks by their app_id. May be combined with + 'active-first', in which case tasks are grouped by app_id first and the + active task is then moved to the front. Both are overridden once the tasks + have been reordered by dragging; see *REORDERING*. + +*homogeneous*: ++ + typeof: bool ++ + default: false ++ + If set to true, distribute every task button evenly across the taskbar's allocated width. Buttons will automatically resize so that 'N' visible tasks each take '1/N' of the width. + +*justify*: ++ + typeof: string ++ + The alignment of the text within the module's box, allowing options 'left', 'right', or 'center' to define the positioning. + +*expand*: ++ + typeof: bool ++ + default: false ++ + If set to true, task buttons stretch to fill the available space in the taskbar and long titles are ellipsized to fit. Only takes effect on a horizontal bar; on a vertical bar the buttons keep their content-based size. If set to false, buttons are sized to their content. + +*truncate*: ++ + typeof: bool ++ + default: false ++ + If set to true, the task button text will be ellipsized (truncated with …) when the available button width is smaller than the label text. + +*on-click*: ++ + typeof: string ++ + The action which should be triggered when clicking on the application button with the left mouse button. + +*on-click-middle*: ++ + typeof: string ++ + The action which should be triggered when clicking on the application button with the middle mouse button. + +*on-click-right*: ++ + typeof: string ++ + The action which should be triggered when clicking on the application button with the right mouse button. + +*on-update*: ++ + typeof: string ++ + Command to execute when the module is updated. + +*ignore-list*: ++ + typeof: array ++ + List of app_id/titles to be invisible. + +*squash-list*: ++ + typeof: array ++ + List of app_id/titles whose multiple instances are collapsed into a single button. When more than one instance of a listed app is open, only one button is shown; when one instance closes, the next hidden instance reappears. The special value '\*' matches all applications. + +*app_ids-mapping*: ++ + typeof: object ++ + Dictionary of app_id to be replaced with + +*rewrite*: ++ + typeof: object ++ + Rules to rewrite the module format output. See *rewrite rules*. + +# FORMAT REPLACEMENTS + +*{icon}*: The icon of the application. + +*{name}*: The application name as in desktop file if appropriate desktop files are found, otherwise same as {app_id} + +*{title}*: The title of the application. + +*{app_id}*: The app_id (== application name) of the application. + +*{state}*: The state (active, fullscreen, urgent) of the application. + +*{short_state}*: The state (active == A, fullscreen == F, urgent == U) represented as one character of the application. + +As with all format replacements in Waybar, a length limit such as *{title:.15}* is +measured in bytes, not characters. This may produce invalid text if a multi-byte +character is split, or (when *markup* is true) if the truncation cuts through a +value that must be escaped in XML. + +# CLICK ACTIONS + +*activate*: Bring the application into foreground. + +*fullscreen*: Toggle application's fullscreen state. + +*close*: Close the application. + +# REORDERING + +Task buttons can be reordered by dragging them onto each other with the left +mouse button. The dragged button is placed at the position of the button it is +dropped on. + +The order produced by dragging takes precedence over *sort-by-app-id* and +*active-first*: from the first drag until Waybar is restarted, those options no +longer move the tasks they apply to. Newly opened windows are appended after +the tasks whose position was recorded by the drag. + +The order is kept in memory only and is not persisted across restarts. + +# REWRITE RULES + +*rewrite* is an object where keys are regular expressions and values are +rewrite rules if the expression matches. Rules may contain references to +captures of the expression. + +Regular expression and replacement follow ECMA-script rules. + +An expression must match the format output *fully* to trigger its replacement. + +If no expression matches, the format output is left unchanged. + +Invalid expressions (e.g., mismatched parentheses) are skipped. + +# EXAMPLES + +``` +"sway/taskbar": { + "format": "{icon}", + "icon-size": 14, + "icon-theme": "Numix-Circle", + "tooltip-format": "{title}", + "on-click": "activate", + "on-click-middle": "close", + "ignore-list": [ + "Alacritty" + ], + "app_ids-mapping": { + "firefoxdeveloperedition": "firefox-developer-edition" + }, + "rewrite": { + "Firefox Web Browser": "Firefox", + "Foot Server": "Terminal" + } +} +``` + +# Style + +- *#taskbar* +- *#taskbar.empty* +- *#taskbar button* +- *#taskbar button.active* +- *#taskbar button.current_output* +- *#taskbar button.fullscreen* +- *#taskbar button.urgent* +- *#taskbar button.visible* + +The *visible* class is set when the window's workspace is the one currently +displayed on its output. With *all-workspaces* set to false every listed window +is on a displayed workspace, so the class is always present; it is chiefly useful +together with *all-workspaces* to de-emphasise windows you are not looking at: + +``` +#taskbar button:not(.visible) { + opacity: 0.5; +} +``` + +The *current_output* class is set when the window is on the same output as the +bar. With *all-outputs* set to false every listed window is on the bar's output, +so the class is always present; it is chiefly useful together with *all-outputs* +to distinguish windows living on other monitors. + +``` +#taskbar button:not(.current_output) { + opacity: 0.5; +} +``` + +# Bar state style + +When *bar-css-states* is enabled, the following classes are added to +*window#waybar*: + +- *window#waybar.toplevel-active* +- *window#waybar.toplevel-fullscreen* +- *window#waybar.toplevel-urgent* + +The active class describes the focused application. The fullscreen and +urgent classes are set if any application on the current workspace has that +state. + +For example: + +``` +window#waybar { + background-color: rgba(0, 0, 0, 0.5); +} + +window#waybar.toplevel-urgent { + background-color: rgba(64, 0, 0, 0.75); +} +``` diff --git a/meson.build b/meson.build index 88be32e27b..7c67a9edf0 100644 --- a/meson.build +++ b/meson.build @@ -284,12 +284,14 @@ if true 'src/modules/sway/language.cpp', 'src/modules/sway/window.cpp', 'src/modules/sway/workspaces.cpp', - 'src/modules/sway/scratchpad.cpp' + 'src/modules/sway/scratchpad.cpp', + 'src/modules/sway/taskbar.cpp' ) man_files += files( 'man/waybar-sway-language.5.scd', 'man/waybar-sway-mode.5.scd', 'man/waybar-sway-scratchpad.5.scd', + 'man/waybar-sway-taskbar.5.scd', 'man/waybar-sway-window.5.scd', 'man/waybar-sway-workspaces.5.scd', ) diff --git a/src/factory.cpp b/src/factory.cpp index 86abb43d2c..6d0138699d 100644 --- a/src/factory.cpp +++ b/src/factory.cpp @@ -11,6 +11,7 @@ #include "modules/sway/language.hpp" #include "modules/sway/mode.hpp" #include "modules/sway/scratchpad.hpp" +#include "modules/sway/taskbar.hpp" #include "modules/sway/window.hpp" #include "modules/sway/workspaces.hpp" #endif @@ -182,6 +183,9 @@ waybar::AModule* waybar::Factory::makeModule(const std::string& name, if (ref == "sway/scratchpad") { return new waybar::modules::sway::Scratchpad(id, config_[name]); } + if (ref == "sway/taskbar") { + return new waybar::modules::sway::Taskbar(id, bar_, config_[name]); + } #endif #ifdef HAVE_WLR_TASKBAR if (ref == "wlr/taskbar") { diff --git a/src/modules/sway/taskbar.cpp b/src/modules/sway/taskbar.cpp new file mode 100644 index 0000000000..9f15f2e7c4 --- /dev/null +++ b/src/modules/sway/taskbar.cpp @@ -0,0 +1,887 @@ +#include "modules/sway/taskbar.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bar.hpp" +#include "util/rewrite_string.hpp" +#include "util/string.hpp" + +namespace waybar::modules::sway { + +namespace { + +const std::vector kTargetEntries = { + Gtk::TargetEntry("WAYBAR_TOPLEVEL", Gtk::TARGET_SAME_APP, 0)}; + +// Returns {app_id, app_class} for a view. Native Wayland views carry an +// `app_id`; XWayland views instead carry the X11 `instance` and `class` hints. +// The instance is used as the displayed app_id (as sway/window does), but the +// class is carried along because it is the identifier icon lookup actually +// needs: X11 Firefox reports instance "Navigator" and class "firefox". +std::pair resolve_app_id( + const Json::Value& node, const std::map& replace_map) { + std::string app_id; + std::string app_class; + if (node["window_properties"]["class"].isString()) { + app_class = node["window_properties"]["class"].asString(); + } + if (node["app_id"].isString()) { + app_id = node["app_id"].asString(); + } else if (node["window_properties"]["instance"].isString()) { + app_id = node["window_properties"]["instance"].asString(); + } else { + app_id = app_class; + } + + const auto replace = [&replace_map](std::string& value) { + const auto it = replace_map.find(value); + if (it != replace_map.end()) { + value = it->second; + } + }; + replace(app_id); + replace(app_class); + return {app_id, app_class}; +} + +bool is_leaf_view(const Json::Value& node) { + const auto type = node["type"].asString(); + return (type == "con" || type == "floating_con") && node["nodes"].empty() && + node["floating_nodes"].empty(); +} + +// Everything Taskbar takes from one GET_TREE response. +struct TreeSnapshot { + std::vector windows; + std::string focused_workspace; +}; + +// Implementation detail of walk_tree() below; single-use, one per response. +// +// Deliberately knows nothing about Taskbar: it runs on the Ipc worker thread and +// so must not reach main-thread state. +class TreeWalker { + public: + TreeWalker(bool all_outputs, bool all_workspaces, std::string_view bar_output, + const std::map& replace_map) + : all_outputs_(all_outputs), + all_workspaces_(all_workspaces), + bar_output_(bar_output), + replace_map_(replace_map) {} + + TreeSnapshot run(const Json::Value& root) { + walk(root, Ancestry{}); + return std::move(result_); + } + + private: + // The nearest ancestor output and workspace at a point in the recursion. A + // workspace is "visible" when its name matches its output's + // `current_workspace` — GET_TREE workspace nodes have no `visible` field + // (unlike GET_WORKSPACES). + struct Ancestry { + std::string output; + std::string output_current_ws; + std::string workspace; + bool workspace_visible = false; + }; + + void walk(const Json::Value& node, Ancestry state); + void collect(const Json::Value& node, const Ancestry& state); + + const bool all_outputs_; + const bool all_workspaces_; + const std::string_view bar_output_; + const std::map& replace_map_; + TreeSnapshot result_; +}; + +// Recursively walks the sway node tree (as returned by GET_TREE), collecting the +// leaf views the caller is configured to show and tracking which workspace is +// currently globally focused. +TreeSnapshot walk_tree(const Json::Value& root, bool all_outputs, bool all_workspaces, + std::string_view bar_output, + const std::map& replace_map) { + return TreeWalker(all_outputs, all_workspaces, bar_output, replace_map).run(root); +} + +void TreeWalker::walk(const Json::Value& node, Ancestry state) { + const auto type = node["type"].asString(); + if (type == "output") { + state.output = node["name"].asString(); + state.output_current_ws = + node["current_workspace"].isString() ? node["current_workspace"].asString() : ""; + } else if (type == "workspace") { + state.workspace = node["name"].asString(); + state.workspace_visible = state.workspace == state.output_current_ws; + if (node["focused"].asBool()) { + result_.focused_workspace = state.workspace; + } + } + + if (is_leaf_view(node)) { + collect(node, state); + return; + } + + for (const auto& child : node["nodes"]) { + walk(child, state); + } + for (const auto& child : node["floating_nodes"]) { + walk(child, state); + } +} + +void TreeWalker::collect(const Json::Value& node, const Ancestry& state) { + if (node["focused"].asBool()) { + result_.focused_workspace = state.workspace; + } + + if ((all_outputs_ || state.output == bar_output_) && + (all_workspaces_ || state.workspace_visible)) { + TaskInfo info; + info.id = node["id"].asInt64(); + info.title = node["name"].isString() ? node["name"].asString() : ""; + std::tie(info.app_id, info.app_class) = resolve_app_id(node, replace_map_); + info.active = node["focused"].asBool(); + info.fullscreen = node["fullscreen_mode"].asInt() != 0; + info.urgent = node["urgent"].asBool(); + info.workspace = state.workspace; + info.workspace_visible = state.workspace_visible; + info.current_output = state.output == bar_output_; + result_.windows.push_back(std::move(info)); + } +} + +} // namespace + +/* Task class implementation */ + +Task::Task(const waybar::Bar& bar, const Json::Value& config, Taskbar* tbar, const TaskInfo& info) + : config_{config}, tbar_{tbar}, id_{info.id}, content_{bar.orientation, 0} { + button.set_relief(Gtk::RELIEF_NONE); + + markup_ = config_["markup"].isBool() && config_["markup"].asBool(); + active_only_ = config_["active-only"].isBool() && config_["active-only"].asBool(); + icon_size_ = config_["icon-size"].isInt() ? config_["icon-size"].asInt() : 16; + + /* When "expand" is enabled the buttons stretch to fill the taskbar and the + * titles ellipsize to fit within the available space. This only makes sense + * on a horizontal bar; see wlr/taskbar for the rationale. */ + const bool expand = config_["expand"].isBool() && config_["expand"].asBool(); + const bool horizontal = bar.orientation == Gtk::ORIENTATION_HORIZONTAL; + const bool truncate = config_["truncate"].isBool() && config_["truncate"].asBool(); + + /* Both "truncate" and the "expand" layout want single-line, ellipsized + * labels; configure that once for either. */ + if (truncate || (expand && horizontal)) { + for (auto* label : {&text_before_, &text_after_}) { + label->set_single_line_mode(true); + label->set_ellipsize(Pango::ELLIPSIZE_END); + label->set_line_wrap(false); + } + } + + if (expand && horizontal) { + button.set_hexpand(true); + content_.set_hexpand(true); + text_before_.set_width_chars(1); + text_before_.set_xalign(0.0); + text_after_.set_width_chars(1); + text_after_.set_xalign(0.0); + + content_.pack_start(text_before_, true, true, 0); + content_.pack_start(icon_, false, false, 0); + content_.pack_start(text_after_, true, true, 0); + } else { + content_.add(text_before_); + content_.add(icon_); + content_.add(text_after_); + } + + if (config_["justify"].isString()) { + auto justify_str = config_["justify"].asString(); + if (justify_str == "left") { + content_.set_halign(Gtk::ALIGN_START); + } else if (justify_str == "right") { + content_.set_halign(Gtk::ALIGN_END); + } else if (justify_str == "center") { + content_.set_halign(Gtk::ALIGN_CENTER); + } + } + + content_.show(); + button.add(content_); + + if (config_["format"].isString()) { + /* The user defined a format string, use it */ + auto format = config_["format"].asString(); + auto parts = split(format, "{icon}", 1); + format_before_ = parts[0]; + if (parts.size() > 1) { + with_icon_ = true; + format_after_ = parts[1]; + } + } else { + /* The default is to only show the icon */ + with_icon_ = true; + } + + if (!config_["tooltip"].isBool() || config_["tooltip"].asBool()) { + if (config_["tooltip-format"].isString()) + format_tooltip_ = config_["tooltip-format"].asString(); + else + format_tooltip_ = "{title}"; + } + + button.signal_button_release_event().connect(sigc::mem_fun(*this, &Task::handleClicked), false); + + /* Reordering is handled by GTK's automatic drag handling: drag_source_set() + * starts the drag, the button's con_id travels in the selection data. */ + button.drag_source_set(kTargetEntries, Gdk::BUTTON1_MASK, Gdk::ACTION_MOVE); + button.drag_dest_set(kTargetEntries, Gtk::DEST_DEFAULT_ALL, Gdk::ACTION_MOVE); + + button.signal_drag_data_get().connect(sigc::mem_fun(*this, &Task::handleDragDataGet), false); + button.signal_drag_data_received().connect(sigc::mem_fun(*this, &Task::handleDragDataReceived), + false); + + setData(info); +} + +Task::~Task() { + if (button_visible_) { + tbar_->removeButton(button); + button_visible_ = false; + } +} + +std::string Task::stateString(bool shortened) const { + std::stringstream ss; + if (shortened) { + ss << (active_ ? "A" : "") << (fullscreen_ ? "F" : "") << (urgent_ ? "U" : ""); + } else { + ss << (active_ ? "active " : "") << (fullscreen_ ? "fullscreen " : "") + << (urgent_ ? "urgent " : ""); + } + + std::string res = ss.str(); + if (shortened || res.empty()) { + return res; + } + return res.substr(0, res.size() - 1); +} + +void Task::applyVisibility(bool ignored, bool squashed) { + if (ignored || squashed) { + hideButton(); + } else { + showButton(); + } +} + +void Task::showButton() { + if (button_visible_) { + return; + } + tbar_->addButton(button); + button_visible_ = true; + + if (!active_only_ || active_) { + button.show(); + } +} + +void Task::hideButton() { + if (!button_visible_) { + return; + } + tbar_->removeButton(button); + button.hide(); + button_visible_ = false; +} + +void Task::setData(const TaskInfo& info) { + /* The desktop file and the icon are keyed on the app_id alone, so they are + * only re-resolved when the app_id actually changes. Titles change on every + * page navigation or shell command; re-scanning desktop files for those + * would cost a Gio/IconTheme lookup per keystroke. */ + const bool identity_changed = + !app_info_resolved_ || app_id_ != info.app_id || app_class_ != info.app_class; + + title_ = info.title; + app_id_ = info.app_id; + app_class_ = info.app_class; + active_ = info.active; + fullscreen_ = info.fullscreen; + urgent_ = info.urgent; + workspace_ = info.workspace; + workspace_visible_ = info.workspace_visible; + current_output_ = info.current_output; + + if (!identity_changed) { + return; + } + app_info_resolved_ = true; + + app_info_ = IconLoader::get_app_info_from_app_id_list(app_id_); + if (!app_info_ && !app_class_.empty() && app_class_ != app_id_) { + app_info_ = IconLoader::get_app_info_from_app_id_list(app_class_); + } + name_ = app_info_ ? app_info_->get_display_name() : app_id_; + + if (!with_icon_) { + return; + } + + if (tbar_->iconLoader().image_load_icon(icon_, app_info_, icon_size_)) { + icon_.show(); + } else { + spdlog::debug("Couldn't find icon for {}", app_id_); + } +} + +bool Task::handleClicked(GdkEventButton* bt) { + std::string action; + if (config_["on-click"].isString() && bt->button == 1) + action = config_["on-click"].asString(); + else if (config_["on-click-middle"].isString() && bt->button == 2) + action = config_["on-click-middle"].asString(); + else if (config_["on-click-right"].isString() && bt->button == 3) + action = config_["on-click-right"].asString(); + + if (action.empty()) { + return true; + } + + try { + if (action == "activate") { + tbar_->ipc().sendCmd(IPC_COMMAND, fmt::format("[con_id={}] focus", id_)); + } else if (action == "close") { + tbar_->ipc().sendCmd(IPC_COMMAND, fmt::format("[con_id={}] kill", id_)); + } else if (action == "fullscreen") { + tbar_->ipc().sendCmd(IPC_COMMAND, fmt::format("[con_id={}] fullscreen toggle", id_)); + } else if (action == "minimize" || action == "minimize-raise" || action == "maximize") { + spdlog::warn("{} is not supported on sway", action); + } else { + spdlog::warn("Unknown action {}", action); + } + } catch (const std::exception& e) { + spdlog::error("Taskbar: {}", e.what()); + } + + return true; +} + +void Task::handleDragDataGet(const Glib::RefPtr& context, + Gtk::SelectionData& selection_data, guint info, guint time) { + spdlog::debug("drag_data_get"); + /* Send the con_id rather than a pointer to this Task's button: the task may + * be destroyed between drag-begin and drop (its window closed, or a tree + * refresh pruned it), and a stale id is a harmless lookup miss. */ + selection_data.set("WAYBAR_TOPLEVEL", std::to_string(id_)); +} + +void Task::handleDragDataReceived(const Glib::RefPtr& context, int x, int y, + Gtk::SelectionData selection_data, guint info, guint time) { + spdlog::debug("drag_data_received"); + const auto payload = selection_data.get_data_as_string(); + int64_t dragged_id = -1; + const auto* first = payload.data(); + const auto* last = payload.data() + payload.size(); + if (std::from_chars(first, last, dragged_id).ec != std::errc{}) { + return; + } + + tbar_->reorderTask(dragged_id, id_); +} + +std::string Task::formatText(const std::string& format, const std::string& title, + const std::string& name, const std::string& app_id) { + try { + return fmt::format(fmt::runtime(format), fmt::arg("title", title), fmt::arg("name", name), + fmt::arg("app_id", app_id), fmt::arg("state", stateString()), + fmt::arg("short_state", stateString(true))); + } catch (const std::exception& e) { + /* A malformed format (e.g. an unknown {placeholder}) makes fmt throw a + * fmt::format_error. update() runs from a Glib::Dispatcher callback, so + * letting it escape would terminate waybar on every update; warn once + * instead. */ + if (!format_warned_) { + spdlog::warn("sway/taskbar: invalid format '{}': {}", format, e.what()); + format_warned_ = true; + } + return {}; + } +} + +void Task::update() { + std::string title = title_; + std::string name = name_; + std::string app_id = app_id_; + if (markup_) { + title = Glib::Markup::escape_text(title); + name = Glib::Markup::escape_text(name); + app_id = Glib::Markup::escape_text(app_id); + } + if (!format_before_.empty()) { + auto txt = formatText(format_before_, title, name, app_id); + txt = waybar::util::rewriteString(txt, config_["rewrite"]); + + if (markup_) + text_before_.set_markup(txt); + else + text_before_.set_label(txt); + text_before_.show(); + } + if (!format_after_.empty()) { + auto txt = formatText(format_after_, title, name, app_id); + txt = waybar::util::rewriteString(txt, config_["rewrite"]); + + if (markup_) + text_after_.set_markup(txt); + else + text_after_.set_label(txt); + text_after_.show(); + } + + if (!format_tooltip_.empty()) { + auto txt = formatText(format_tooltip_, title, name, app_id); + txt = waybar::util::rewriteString(txt, config_["rewrite"]); + + if (markup_) + button.set_tooltip_markup(txt); + else + button.set_tooltip_text(txt); + } + + auto style = button.get_style_context(); + if (active_) + style->add_class("active"); + else + style->remove_class("active"); + + if (fullscreen_) + style->add_class("fullscreen"); + else + style->remove_class("fullscreen"); + + if (urgent_) + style->add_class("urgent"); + else + style->remove_class("urgent"); + + if (workspace_visible_) + style->add_class("visible"); + else + style->remove_class("visible"); + + if (current_output_) + style->add_class("current_output"); + else + style->remove_class("current_output"); + + if (button_visible_ && active_only_) { + if (active_) + button.show(); + else + button.hide(); + } +} + +/* Taskbar class implementation */ + +Taskbar::Taskbar(const std::string& id, const waybar::Bar& bar, const Json::Value& config) + : waybar::AModule(config, "taskbar", id, false, false), bar_(bar), box_{bar.orientation, 0} { + box_.set_name("taskbar"); + if (!id.empty()) { + box_.get_style_context()->add_class(id); + } + box_.get_style_context()->add_class(MODULE_CLASS); + box_.get_style_context()->add_class("empty"); + event_box_.add(box_); + + bar_css_states_ = config_["bar-css-states"].isBool() && config_["bar-css-states"].asBool(); + sort_by_app_id_ = config_["sort-by-app-id"].isBool() && config_["sort-by-app-id"].asBool(); + active_first_ = config_["active-first"].isBool() && config_["active-first"].asBool(); + homogeneous_ = config_["homogeneous"].isBool() && config_["homogeneous"].asBool(); + expand_ = config_["expand"].isBool() && config_["expand"].asBool(); + + // sway/taskbar interprets on-click* config values as built-in actions, handled + // per-task in Task::handleClicked. Register the recognized action names so + // AModule dispatches them via doAction() instead of also running them as shell + // commands (mirrors wlr/taskbar, see issue #3284). + const auto is_builtin_action = [](const std::string& v) { + return v == "activate" || v == "minimize" || v == "minimize-raise" || v == "maximize" || + v == "fullscreen" || v == "close"; + }; + for (const auto* event : {"on-click", "on-click-middle", "on-click-right"}) { + if (config_[event].isString() && is_builtin_action(config_[event].asString())) { + eventActionMap_.insert({event, config_[event].asString()}); + } + } + + // Make task buttons distribute evenly across the available width. + if (homogeneous_) { + box_.set_homogeneous(true); + box_.set_hexpand(true); + } + + /* Get the configured icon theme if specified */ + if (config_["icon-theme"].isArray()) { + for (auto& c : config_["icon-theme"]) { + icon_loader_.add_custom_icon_theme(c.asString()); + } + } else if (config_["icon-theme"].isString()) { + icon_loader_.add_custom_icon_theme(config_["icon-theme"].asString()); + } + + // Load ignore-list + if (config_["ignore-list"].isArray()) { + for (auto& app_name : config_["ignore-list"]) { + ignore_list_.emplace(app_name.asString()); + } + } + + // Load squash-list + if (config_["squash-list"].isArray()) { + for (auto& app_name : config_["squash-list"]) { + squash_list_.emplace(app_name.asString()); + } + } + + // Load app_id remappings + if (config_["app_ids-mapping"].isObject()) { + const Json::Value& mapping = config_["app_ids-mapping"]; + const std::vector app_ids = mapping.getMemberNames(); + for (auto& app_id : app_ids) { + app_ids_replace_map_.emplace(app_id, mapping[app_id].asString()); + } + } + + ipc_.subscribe(R"(["window"])"); + ipc_.subscribe(R"(["workspace"])"); + ipc_.signal_event.connect(sigc::mem_fun(*this, &Taskbar::onEvent)); + ipc_.signal_cmd.connect(sigc::mem_fun(*this, &Taskbar::onCmd)); + + try { + ipc_.sendCmd(IPC_GET_TREE); + } catch (const std::exception& e) { + spdlog::error("Taskbar: {}", e.what()); + } + + // Launch worker + ipc_.setWorker([this] { + try { + ipc_.handleEvent(); + } catch (const std::exception& e) { + spdlog::error("Taskbar: {}", e.what()); + } + }); +} + +Taskbar::~Taskbar() { + if (bar_css_states_) { + setBarCssClass("toplevel-active", false); + setBarCssClass("toplevel-fullscreen", false); + setBarCssClass("toplevel-urgent", false); + } +} + +void Taskbar::onEvent(const struct Ipc::ipc_response& res) { + try { + ipc_.sendCmd(IPC_GET_TREE); + } catch (const std::exception& e) { + spdlog::error("Taskbar: {}", e.what()); + } +} + +void Taskbar::onCmd(const struct Ipc::ipc_response& res) { + if (res.type != IPC_GET_TREE) { + return; + } + try { + // parser_ and the local snapshot are worker-thread only; only the handover + // to windows_/current_workspace_ needs the lock, so the main thread never + // blocks behind a full tree parse. + auto payload = parser_.parse(res.payload); + + auto snapshot = + walk_tree(payload, allOutputs(), allWorkspaces(), bar_.output->name, app_ids_replace_map_); + + { + std::lock_guard lock(mutex_); + windows_ = std::move(snapshot.windows); + current_workspace_ = std::move(snapshot.focused_workspace); + } + dp.emit(); + } catch (const std::exception& e) { + spdlog::error("Taskbar: {}", e.what()); + } +} + +void Taskbar::update() { + std::vector windows; + std::string current_workspace; + { + std::lock_guard lock(mutex_); + windows = windows_; + current_workspace = current_workspace_; + } + + // Drop tasks that disappeared from the tree; ~Task() removes their button. + tasks_.erase(std::remove_if(tasks_.begin(), tasks_.end(), + [&windows](const TaskPtr& task) { + return std::none_of(windows.begin(), windows.end(), + [&task](const TaskInfo& info) { + return info.id == task->id(); + }); + }), + tasks_.end()); + + // Reconcile / create tasks for the snapshot, preserving tree order. + std::vector ordered; + ordered.reserve(windows.size()); + for (const auto& info : windows) { + auto it = std::find_if(tasks_.begin(), tasks_.end(), + [&info](const TaskPtr& task) { return task->id() == info.id; }); + Task* task = nullptr; + if (it == tasks_.end()) { + tasks_.push_back(std::make_unique(bar_, config_, this, info)); + task = tasks_.back().get(); + } else { + (*it)->setData(info); + task = it->get(); + } + ordered.push_back(task); + } + + // Ordering: tree order, optionally overridden by sort-by-app-id and/or + // active-first, then overridden again by any persisted user drag order. + if (sort_by_app_id_) { + std::stable_sort(ordered.begin(), ordered.end(), + [](Task* a, Task* b) { return a->app_id() < b->app_id(); }); + } + + if (active_first_) { + auto it = std::find_if(ordered.begin(), ordered.end(), [](Task* t) { return t->active(); }); + if (it != ordered.end() && it != ordered.begin()) { + std::rotate(ordered.begin(), it, std::next(it)); + } + } + + if (!user_order_.empty()) { + std::vector final_order; + final_order.reserve(ordered.size()); + for (int64_t id : user_order_) { + auto it = + std::find_if(ordered.begin(), ordered.end(), [id](Task* t) { return t->id() == id; }); + if (it != ordered.end()) { + final_order.push_back(*it); + } + } + for (Task* t : ordered) { + if (std::find(final_order.begin(), final_order.end(), t) == final_order.end()) { + final_order.push_back(t); + } + } + ordered = std::move(final_order); + } + + // Ignore-list / squash-list visibility. Computed here (not per-task) because + // squashing must keep exactly one instance of a group visible: the first one + // in display order stays, the rest are hidden. Runs after every task's data + // has been refreshed via setData()/create above so duplicate counts are final. + std::unordered_set shown_groups; + for (auto* task : ordered) { + const bool ignored = + ignore_list_.contains(task->app_id()) || ignore_list_.contains(task->title()); + bool squashed = false; + if (!ignored) { + // The squash group is identified by the squash-list entry that matched + // (for "*", by the task's own app_id/title), and is qualified by which + // field matched, so two unrelated apps cannot land in the same group just + // because one's title equals the other's app_id. + std::string group; + bool duplicated = false; + if (squash_list_.contains(task->app_id())) { + group = "app_id:" + task->app_id(); + duplicated = taskAppIdCount(task->app_id()) > 1; + } else if (squash_list_.contains(task->title())) { + group = "title:" + task->title(); + duplicated = taskTitleCount(task->title()) > 1; + } else if (squash_list_.contains("*")) { + if (!task->app_id().empty()) { + group = "app_id:" + task->app_id(); + duplicated = taskAppIdCount(task->app_id()) > 1; + } else if (!task->title().empty()) { + group = "title:" + task->title(); + duplicated = taskTitleCount(task->title()) > 1; + } + } + if (!group.empty() && duplicated) { + // Not the first shown member of the group -> squash. + squashed = !shown_groups.insert(group).second; + } + } + task->applyVisibility(ignored, squashed); + } + + int pos = 0; + for (auto* t : ordered) { + if (t->visible()) { + moveButton(t->button, pos++); + } + } + + // Render every task (labels/icon/tooltip/state classes, active-only visibility). + for (auto* t : ordered) { + t->update(); + } + + if (bar_css_states_) { + bool has_active = false; + bool has_fullscreen = false; + bool has_urgent = false; + for (auto* t : ordered) { + if (!t->visible()) { + continue; + } + if (t->active()) { + has_active = true; + } + if (t->workspace() == current_workspace) { + if (t->fullscreen()) { + has_fullscreen = true; + } + if (t->urgent()) { + has_urgent = true; + } + } + } + setBarCssClass("toplevel-active", has_active); + setBarCssClass("toplevel-fullscreen", has_fullscreen); + setBarCssClass("toplevel-urgent", has_urgent); + } + + // Sole owner of the "empty" class: it tracks buttons that are actually + // rendered, which with "active-only" is not the same as buttons packed into + // the box. Runs after every add/remove path. + const bool any_shown = + std::any_of(ordered.begin(), ordered.end(), [](const Task* t) { return t->shown(); }); + if (any_shown) { + box_.get_style_context()->remove_class("empty"); + } else { + box_.get_style_context()->add_class("empty"); + } + + AModule::update(); +} + +void Taskbar::addButton(Gtk::Button& bt) { + /* When "homogeneous" is enabled, let every child expand and fill so the buttons + * divide the available width equally. Otherwise, only let the buttons expand to + * fill the taskbar when "expand" is enabled and the bar is horizontal (see the + * Task constructor for details). */ + const bool horizontal = bar_.orientation == Gtk::ORIENTATION_HORIZONTAL; + if (homogeneous_) { + box_.pack_start(bt, true, true); + bt.set_hexpand(true); + bt.set_halign(Gtk::ALIGN_FILL); + } else if (expand_ && horizontal) { + box_.pack_start(bt, true, true); + } else { + box_.pack_start(bt, false, false); + } +} + +void Taskbar::moveButton(Gtk::Button& bt, int pos) { box_.reorder_child(bt, pos); } + +void Taskbar::removeButton(Gtk::Button& bt) { box_.remove(bt); } + +void Taskbar::reorderTask(int64_t dragged_id, int64_t target_id) { + if (dragged_id == target_id) { + return; + } + + const auto find = [this](int64_t id) -> Task* { + auto it = std::find_if(tasks_.begin(), tasks_.end(), + [id](const TaskPtr& task) { return task->id() == id; }); + return it == tasks_.end() ? nullptr : it->get(); + }; + + Task* dragged = find(dragged_id); + Task* target = find(target_id); + // The dragged window may have closed mid-drag, in which case its Task is + // already gone: nothing to reorder. + if (dragged == nullptr || target == nullptr || !dragged->visible() || !target->visible()) { + return; + } + + const auto position = box_.child_property_position(target->button).get_value(); + box_.reorder_child(dragged->button, position); + + recordUserOrder(); +} + +void Taskbar::recordUserOrder() { + user_order_.clear(); + for (auto* child : box_.get_children()) { + auto it = std::find_if(tasks_.begin(), tasks_.end(), [child](const TaskPtr& task) { + return static_cast(&task->button) == child; + }); + if (it != tasks_.end()) { + user_order_.push_back((*it)->id()); + } + } +} + +bool Taskbar::allOutputs() const { + return config_["all-outputs"].isBool() && config_["all-outputs"].asBool(); +} + +// Defaults to "all-outputs" so that setting only "all-outputs" matches +// wlr/taskbar, which this module can stand in for. +bool Taskbar::allWorkspaces() const { + const auto& value = config_["all-workspaces"]; + return value.isBool() ? value.asBool() : allOutputs(); +} + +Ipc& Taskbar::ipc() { return ipc_; } + +const IconLoader& Taskbar::iconLoader() const { return icon_loader_; } + +std::size_t Taskbar::taskAppIdCount(std::string_view app_id) const { + return std::count_if(tasks_.begin(), tasks_.end(), + [app_id](const TaskPtr& task) { return app_id == task->app_id(); }); +} + +std::size_t Taskbar::taskTitleCount(std::string_view title) const { + return std::count_if(tasks_.begin(), tasks_.end(), + [title](const TaskPtr& task) { return title == task->title(); }); +} + +void Taskbar::setBarCssClass(const std::string& class_name, bool enabled) { + const auto style = bar_.window.get_style_context(); + if (enabled && !style->has_class(class_name)) { + spdlog::trace("Adding bar class: {}", class_name); + style->add_class(class_name); + } else if (!enabled && style->has_class(class_name)) { + spdlog::trace("Removing bar class: {}", class_name); + style->remove_class(class_name); + } +} + +} // namespace waybar::modules::sway diff --git a/test/smoke/coverage.sh b/test/smoke/coverage.sh index 9969d6796e..9103802b33 100755 --- a/test/smoke/coverage.sh +++ b/test/smoke/coverage.sh @@ -25,7 +25,7 @@ HARDWARE=(backlight backlight/slider battery upower power-profiles-daemon blueto mpd mpris temperature keyboard-state systemd-failed-units privacy tray gamemode) # Need a specific compositor / its IPC to produce output. -COMPOSITOR=(sway/language sway/mode sway/scratchpad sway/window sway/workspaces +COMPOSITOR=(sway/language sway/mode sway/scratchpad sway/taskbar sway/window sway/workspaces hyprland/language hyprland/submap hyprland/window hyprland/windowcount hyprland/workspaces river/layout river/mode river/tags river/window niri/language niri/window niri/workspaces