Customize and reorder widgets in Control Center (#150)

* Initial widget code with Title and DND widgets

* Fixed linting issues

* Added label widget

* Added label to widgets JSON Schema

* Added default widgets

* Added info to swaync 5 man page

* Updated README with widget info

* Added ability for multiple configs per widget

* Reworked how the widget CSS classes are applied

* Fixed linting issues

* Added CSS class names to man page
This commit is contained in:
Erik Reider 2022-08-03 17:02:09 +02:00 committed by GitHub
parent 7716e5eeaf
commit 14a327603c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 623 additions and 62 deletions

View File

@ -21,6 +21,16 @@ A simple notification daemon with a GTK gui for notifications and the control ce
- The same features as any other basic notification daemon
- Basic configuration through a JSON config file
- Hot-reload config through `swaync-client`
- Customizable widgets
## Available Widgets
These widgets can be customized, added, removed and even reordered
- Title
- Do Not Disturb
- Notifications (Will always be visible)
- Label
## Planned Features

View File

@ -156,6 +156,102 @@ config file to be able to detect config errors
}
```
*widgets* ++
type: array ++
Default values: ["title", "dnd", "notifications"] ++
Valid array values (see *widget-config* for more information): ++
*notifications*++
required: true ++
optional: false ++
*title*++
optional: true ++
*dnd*++
optional: true ++
*label*++
optional: true ++
description: Which order and which widgets to display. ++
If the \"notifications\" widget isn't specified, it ++
will be placed at the bottom. ++
example:
```
{
"widgets": [
"title",
"dnd",
"notifications"
]
}
```
*widget-config* ++
type: object ++
description: Configure specific widget properties. ++
Widgets to customize: ++
*title*++
type: object ++
css class: widget-title ++
properties: ++
text: ++
type: string ++
optional: true ++
default: "Do Not Disturb" ++
description: The title of the widget ++
clear-all-button: ++
type: bool ++
optional: true ++
default: true ++
description: Wether to display a "Clear All" button ++
button-text: ++
type: string ++
optional: true ++
default: "Clear All" ++
description: "Clear All" button text ++
description: The notification visibility state. ++
*dnd*++
type: object ++
css class: widget-dnd ++
properties: ++
text: ++
type: string ++
optional: true ++
default: "Do Not Disturb" ++
description: The title of the widget ++
description: Control Center Do Not Disturb Widget. ++
*label*++
type: object ++
css class: widget-label ++
properties: ++
text: ++
type: string ++
optional: true ++
default: "Label Text" ++
description: The text content of the widget ++
clear-all-button: ++
type: integer ++
optional: true ++
default: 5 ++
description: The maximum lines ++
description: A generic widget that allows the user to add custom text. ++
example:
```
{
"widget-config": {
"title": {
"text": "Notifications",
"clear-all-button": true,
"button-text": "Clear All"
},
"dnd": {
"text": "Do Not Disturb"
},
"label": {
"max-lines": 5,
"text": "Label Text"
}
}
}
```
# IF BUILT WITH SCRIPTING
*script-fail-notify* ++

View File

@ -34,5 +34,24 @@
"urgency": "Low",
"app-name": "Spotify"
}
},
"widgets": [
"title",
"dnd",
"notifications"
],
"widget-config": {
"title": {
"text": "Notifications",
"clear-all-button": true,
"button-text": "Clear All"
},
"dnd": {
"text": "Do Not Disturb"
},
"label": {
"max-lines": 5,
"text": "Label Text"
}
}
}

View File

@ -503,6 +503,20 @@ namespace SwayNotificationCenter {
}
}
/** Widgets to show in ControlCenter */
public GenericArray<string> widgets {
get;
set;
default = new GenericArray<string> ();
}
/** Widgets to show in ControlCenter */
public HashTable<string, Json.Object> widget_config {
get;
set;
default = new HashTable<string, Json.Object> (str_hash, str_equal);
}
/* Methods */
/**
@ -544,6 +558,34 @@ namespace SwayNotificationCenter {
value = result;
return status;
#endif
case "widgets":
bool status;
GenericArray<string> result =
extract_array<string> (property_name,
property_node,
out status);
value = result;
return status;
case "widget-config":
HashTable<string, Json.Object> result
= new HashTable<string, Json.Object> (str_hash, str_equal);
if (property_node.get_value_type ().name () != "JsonObject") {
value = result;
return true;
}
Json.Object obj = property_node.get_object ();
if (obj.get_size () == 0) {
value = result;
return true;
}
foreach (var key in obj.get_members ()) {
Json.Node ? node = obj.get_member (key);
if (node.get_node_type () != Json.NodeType.OBJECT) continue;
Json.Object ? o = node.get_object ();
if (o != null) result.set (key, o);
}
value = result;
return true;
default:
// Handles all other properties
return default_deserialize_property (
@ -588,6 +630,16 @@ namespace SwayNotificationCenter {
node.set_object (serialize_hashtable<Script> (table));
break;
#endif
case "widgets":
node = new Json.Node (Json.NodeType.ARRAY);
var table = (GenericArray<string>) value.get_boxed ();
node.set_array (serialize_array<string> (table));
break;
case "widget-config":
node = new Json.Node (Json.NodeType.OBJECT);
var table = (HashTable<string, Json.Object>) value.get_boxed ();
node.set_object (serialize_hashtable<Json.Object> (table));
break;
default:
node.set_value (value);
break;
@ -728,6 +780,18 @@ namespace SwayNotificationCenter {
var node = Json.gobject_serialize (item as Object);
json_object.set_member (key, node);
break;
case Type.BOXED:
switch (typeof (T).name ()) {
case "JsonObject":
json_object.set_object_member (key,
(Json.Object) item);
break;
case "JsonArray":
json_object.set_array_member (key,
(Json.Array) item);
break;
}
break;
}
}
return json_object;

View File

@ -1,4 +1,5 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SwayNotificationCenter JSON schema",
"type": "object",
"additionalProperties": false,
@ -127,7 +128,7 @@
},
"scripts": {
"type": "object",
"description": "Which scripts to check and potentially run for every notification. If the notification doesn't include one of the properites, that property will be ignored. All properties (except for exec) use regex. If all properties match the given notification, the script will be run. Only the first matching script will be run.",
"description": "Which scripts to check and potentially run for every notification. If the notification doesn't include one of the properties, that property will be ignored. All properties (except for exec) use regex. If all properties match the given notification, the script will be run. Only the first matching script will be run.",
"minProperties": 1,
"additionalProperties": false,
"patternProperties": {
@ -170,7 +171,7 @@
},
"notification-visibility": {
"type": "object",
"description": "Set the visibility of each incoming notification. If the notification doesn't include one of the properites, that property will be ignored. All properties (except for state) use regex. If all properties match the given notification, the notification will be follow the provided state. Only the first matching object will be used.",
"description": "Set the visibility of each incoming notification. If the notification doesn't include one of the properties, that property will be ignored. All properties (except for state) use regex. If all properties match the given notification, the notification will be follow the provided state. Only the first matching object will be used.",
"minProperties": 1,
"additionalProperties": false,
"patternProperties": {
@ -212,6 +213,88 @@
}
}
}
},
"widgets": {
"type": "array",
"description": "Which order and which widgets to display. If the \"notifications\" widget isn't specified, it will be placed at the bottom.",
"default": ["title", "dnd", "notifications"],
"items": {
"type": "string",
// Sadly can't use regex and enums at the same time. Fix in the future?
"pattern": "^[a-zA-Z0-9_-]{1,}(#[a-zA-Z0-9_-]{1,}){0,1}?$"
}
},
"widget-config": {
"type": "object",
"description": "Configure specific widget properties.",
"additionalProperties": false,
"patternProperties": {
// New widgets go here
"^title(#[a-zA-Z0-9_-]{1,}){0,1}?$": {
// References the widget structure from "widgets" below
"$ref": "#/widgets/title"
},
"^dnd(#[a-zA-Z0-9_-]{1,}){0,1}?$": {
"$ref": "#/widgets/dnd"
},
"^label(#[a-zA-Z0-9_-]{1,}){0,1}?$": {
"$ref": "#/widgets/label"
}
}
}
},
"widgets": {
// New widgets go here
"title": {
"type": "object",
"description": "Control Center Title Widget",
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"description": "The title of the widget",
"default": "Notifications"
},
"clear-all-button": {
"type": "boolean",
"description": "Wether to display a \"Clear All\" button",
"default": true
},
"button-text": {
"type": "string",
"description": "\"Clear All\" button text",
"default": "Clear All"
}
}
},
"dnd": {
"type": "object",
"description": "Control Center Do Not Disturb Widget",
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"description": "The title of the widget",
"default": "Do Not Disturb"
}
}
},
"label": {
"type": "object",
"description": "A generic widget that allows the user to add custom text",
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"description": "The text content of the widget",
"default": "Label Text"
},
"max-lines": {
"type": "integer",
"description": "The maximum lines",
"default": 5
}
}
}
}
}

View File

@ -48,8 +48,7 @@
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">2</property>
<property name="position">0</property>
</packing>
</child>
<style>

View File

@ -11,9 +11,6 @@ namespace SwayNotificationCenter {
[GtkChild]
unowned Gtk.Box box;
private Gtk.Switch dnd_button;
private Gtk.Button clear_all_button;
private SwayncDaemon swaync_daemon;
private NotiDaemon noti_daemon;
@ -23,6 +20,9 @@ namespace SwayNotificationCenter {
private bool list_reverse = false;
private Gtk.Align list_align = Gtk.Align.START;
private Array<Gtk.Widget> widgets = new Array<Gtk.Widget> ();
private const string[] DEFAULT_WIDGETS = { "title", "dnd", "notifications" };
public ControlCenter (SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
this.swaync_daemon = swaync_daemon;
this.noti_daemon = noti_daemon;
@ -102,7 +102,11 @@ namespace SwayNotificationCenter {
close_all_notifications ();
break;
case "D":
set_switch_dnd_state (!dnd_button.get_state ());
try {
swaync_daemon.toggle_dnd ();
} catch (Error e) {
error ("Error: %s\n", e.message);
}
break;
case "Down":
if (list_position + 1 < children.length ()) {
@ -136,23 +140,43 @@ namespace SwayNotificationCenter {
return true;
});
clear_all_button = new Gtk.Button.with_label ("Clear All");
clear_all_button.get_style_context ().add_class (
"control-center-clear-all");
clear_all_button.clicked.connect (close_all_notifications);
this.box.add (new TopAction ("Notifications",
clear_all_button,
true));
add_widgets ();
}
dnd_button = new Gtk.Switch () {
state = noti_daemon.dnd,
};
dnd_button.get_style_context ().add_class ("control-center-dnd");
dnd_button.state_set.connect ((widget, state) => {
noti_daemon.dnd = state;
return false;
});
this.box.add (new TopAction ("Do Not Disturb", dnd_button, false));
/** Adds all custom widgets. Removes previous widgets */
public void add_widgets () {
// Remove all widgets
while (widgets.length > 0) {
uint i = widgets.length - 1;
widgets.index (i).destroy ();
widgets.remove_index (i);
}
string[] w = ConfigModel.instance.widgets.data;
if (w.length == 0) w = DEFAULT_WIDGETS;
bool has_notification = false;
foreach (string key in w) {
// Reposition the scrolled_window
if (key == "notifications") {
has_notification = true;
uint pos = box.get_children ().length ();
box.reorder_child (scrolled_window, (int) (pos > 0 ? --pos : 0));
continue;
}
// Add the widget if it is valid
Gtk.Widget ? widget = Widgets.get_widget_from_key (key,
swaync_daemon,
noti_daemon);
if (widget == null || !(widget is Widgets.BaseWidget)) continue;
widgets.append_val (widget);
box.pack_start (
widgets.index (widgets.length - 1), false, true, 0);
}
if (!has_notification) {
warning ("Notification widget not included in \"widgets\" config. Using default bottom position");
uint pos = box.get_children ().length ();
box.reorder_child (scrolled_window, (int) (pos > 0 ? --pos : 0));
}
}
private bool blank_window_press (Gdk.Event event) {
@ -215,16 +239,12 @@ namespace SwayNotificationCenter {
// Set cc widget position
list_reverse = false;
list_align = Gtk.Align.START;
this.box.set_child_packing (
scrolled_window, true, true, 0, Gtk.PackType.END);
break;
case PositionY.BOTTOM:
align_y = Gtk.Align.END;
// Set cc widget position
list_reverse = true;
list_align = Gtk.Align.END;
this.box.set_child_packing (
scrolled_window, true, true, 0, Gtk.PackType.START);
break;
}
// Fit the ControlCenter to the monitor height
@ -316,10 +336,6 @@ namespace SwayNotificationCenter {
this.visible);
}
public void set_switch_dnd_state (bool state) {
if (this.dnd_button.state != state) this.dnd_button.state = state;
}
public bool toggle_visibility () {
var cc_visibility = !this.visible;
if (this.visible != cc_visibility) {

View File

@ -0,0 +1,69 @@
namespace SwayNotificationCenter.Widgets {
public abstract class BaseWidget : Gtk.Box {
public abstract string widget_name { get; }
public string key { get; private set; }
public string suffix { get; private set; }
public unowned SwayncDaemon swaync_daemon;
public unowned NotiDaemon noti_daemon;
protected BaseWidget (string suffix, SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
this.suffix = suffix;
this.key = widget_name + (suffix.length > 0 ? "#%s".printf (suffix) : "");
this.swaync_daemon = swaync_daemon;
this.noti_daemon = noti_daemon;
get_style_context ().add_class ("widget-%s".printf (widget_name));
if (suffix.length > 0) get_style_context ().add_class (suffix);
}
protected Json.Object ? get_config (Gtk.Widget widget) {
unowned HashTable<string, Json.Object> config
= ConfigModel.instance.widget_config;
string ? orig_key = null;
Json.Object ? props = null;
bool result = config.lookup_extended (key, out orig_key, out props);
if (!result || orig_key == null || props == null) {
critical ("%s: Config not found! Using default config...\n", key);
return null;
}
return props;
}
protected void get_prop<T> (Json.Object config, string value_key, ref T value) {
if (!config.has_member (value_key)) {
warning ("%s: Config doesn't have key: %s!\n", key, value_key);
return;
}
var member = config.get_member (value_key);
Type base_type = Functions.get_base_type (member.get_value_type ());
Type generic_base_type = Functions.get_base_type (typeof (T));
// Convert all INTs to INT64
if (generic_base_type == Type.INT) generic_base_type = Type.INT64;
if (!base_type.is_a (generic_base_type)) {
warning ("%s: Config type %s doesn't match: %s!\n",
key,
typeof (T).name (),
member.get_value_type ().name ());
return;
}
switch (typeof (T)) {
case Type.STRING:
value = member.get_string ();
break;
case Type.INT:
case Type.INT64:
value = member.get_int ();
break;
case Type.BOOLEAN:
value = member.get_boolean ();
break;
}
return;
}
}
}

View File

@ -0,0 +1,53 @@
namespace SwayNotificationCenter.Widgets {
public class Dnd : BaseWidget {
public override string widget_name {
get {
return "dnd";
}
}
Gtk.Label title_widget;
Gtk.Switch dnd_button;
// Default config values
string title = "Do Not Disturb";
public Dnd (string suffix, SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
base (suffix, swaync_daemon, noti_daemon);
Json.Object ? config = get_config (this);
if (config != null) {
// Get title
get_prop<string> (config, "text", ref title);
}
// Title
title_widget = new Gtk.Label (title);
add (title_widget);
// Dnd button
dnd_button = new Gtk.Switch () {
state = noti_daemon.dnd,
};
dnd_button.state_set.connect (state_set);
noti_daemon.on_dnd_toggle.connect ((dnd) => {
dnd_button.state_set.disconnect (state_set);
dnd_button.set_active (dnd);
dnd_button.state_set.connect (state_set);
});
dnd_button.set_can_focus (false);
dnd_button.valign = Gtk.Align.CENTER;
// Backwards compatible torwards older CSS stylesheets
dnd_button.get_style_context ().add_class ("control-center-dnd");
pack_end (dnd_button, false);
show_all ();
}
private bool state_set (Gtk.Widget widget, bool state) {
noti_daemon.dnd = state;
return false;
}
}
}

View File

@ -0,0 +1,27 @@
namespace SwayNotificationCenter.Widgets {
public static Gtk.Widget ? get_widget_from_key (owned string key,
SwayncDaemon swaync_daemon,
NotiDaemon noti_daemon) {
string[] key_seperated = key.split ("#");
string suffix = "";
if (key_seperated.length > 0) key = key_seperated[0];
if (key_seperated.length > 1) suffix = key_seperated[1];
BaseWidget widget;
switch (key) {
case "title":
widget = new Title (suffix, swaync_daemon, noti_daemon);
break;
case "dnd":
widget = new Dnd (suffix, swaync_daemon, noti_daemon);
break;
case "label":
widget = new Label (suffix, swaync_daemon, noti_daemon);
break;
default:
warning ("Could not find widget: \"%s\"!", key);
return null;
}
message ("Loading widget: %s", widget.key);
return widget;
}
}

View File

@ -0,0 +1,44 @@
namespace SwayNotificationCenter.Widgets {
public class Label : BaseWidget {
public override string widget_name {
get {
return "label";
}
}
Gtk.Label label_widget;
// Default config values
string text = "Label Text";
int max_lines = 5;
public Label (string suffix, SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
base (suffix, swaync_daemon, noti_daemon);
Json.Object ? config = get_config (this);
if (config != null) {
// Get text
get_prop<string> (config, "text", ref text);
// Get max lines
get_prop<int> (config, "max-lines", ref max_lines);
}
label_widget = new Gtk.Label (null);
label_widget.set_text (text);
label_widget.set_ellipsize (Pango.EllipsizeMode.END);
label_widget.set_line_wrap (true);
label_widget.set_lines (max_lines);
// Without this and pack_start fill, the label would expand to
// the monitors full width... GTK bug!...
label_widget.set_max_width_chars (0);
label_widget.set_line_wrap_mode (Pango.WrapMode.WORD_CHAR);
label_widget.set_justify (Gtk.Justification.LEFT);
label_widget.set_alignment (0, 0);
pack_start (label_widget, true, true, 0);
show_all ();
}
}
}

View File

@ -0,0 +1,51 @@
namespace SwayNotificationCenter.Widgets {
public class Title : BaseWidget {
public override string widget_name {
get {
return "title";
}
}
Gtk.Label title_widget;
Gtk.Button clear_all_button;
// Default config values
string title = "Notifications";
bool has_clear_all_button = true;
string button_text = "Clear All";
public Title (string suffix, SwayncDaemon swaync_daemon, NotiDaemon noti_daemon) {
base (suffix, swaync_daemon, noti_daemon);
Json.Object ? config = get_config (this);
if (config != null) {
// Get title
get_prop<string> (config, "text", ref title);
// Get has clear-all-button
get_prop<bool> (config, "clear-all-button", ref has_clear_all_button);
get_prop<string> (config, "button-text", ref button_text);
}
title_widget = new Gtk.Label (title);
add (title_widget);
if (has_clear_all_button) {
clear_all_button = new Gtk.Button.with_label (button_text);
clear_all_button.clicked.connect (() => {
try {
swaync_daemon.close_all_notifications ();
} catch (Error e) {
error ("Error: %s\n", e.message);
}
});
clear_all_button.set_can_focus (false);
clear_all_button.valign = Gtk.Align.CENTER;
// Backwards compatible torwards older CSS stylesheets
clear_all_button.get_style_context ().add_class ("control-center-clear-all");
pack_end (clear_all_button, false);
}
show_all ();
}
}
}

View File

@ -22,6 +22,16 @@ constants = configure_file(
configuration : const_config_data
)
widget_sources = [
# Helpers
'controlCenter/widgets/baseWidget.vala',
'controlCenter/widgets/factory.vala',
# Widgets
'controlCenter/widgets/title/title.vala',
'controlCenter/widgets/dnd/dnd.vala',
'controlCenter/widgets/label/label.vala',
]
app_sources = [
'main.vala',
'configModel/configModel.vala',
@ -31,6 +41,7 @@ app_sources = [
'notificationWindow/notificationWindow.vala',
'notification/notification.vala',
'controlCenter/controlCenter.vala',
widget_sources,
'controlCenter/topAction/topAction.vala',
'blankWindow/blankWindow.vala',
'functions.vala',

View File

@ -159,35 +159,6 @@
text-shadow: none;
}
.control-center-clear-all {
color: white;
text-shadow: none;
background: @noti-bg;
border: 1px solid @noti-border-color;
box-shadow: none;
border-radius: 12px;
}
.control-center-clear-all:hover {
background: @noti-bg-hover;
}
.control-center-dnd {
border-radius: 12px;
background: @noti-bg;
border: 1px solid @noti-border-color;
box-shadow: none;
}
.control-center-dnd:checked {
background: @bg-selected;
}
.control-center-dnd slider {
background: @noti-bg-hover;
border-radius: 12px;
}
.control-center {
background: @cc-bg;
}
@ -204,3 +175,51 @@
.blank-window {
background: alpha(black, 0.25);
}
/*** Widgets ***/
/* Title widget */
.widget-title {
margin: 8px;
font-size: 1.5rem;
}
.widget-title > button {
font-size: initial;
color: white;
text-shadow: none;
background: @noti-bg;
border: 1px solid @noti-border-color;
box-shadow: none;
border-radius: 12px;
}
.widget-title > button:hover {
background: @noti-bg-hover;
}
/* DND widget */
.widget-dnd {
margin: 8px;
font-size: 1.1rem;
}
.widget-dnd > switch {
font-size: initial;
border-radius: 12px;
background: @noti-bg;
border: 1px solid @noti-border-color;
box-shadow: none;
}
.widget-dnd > switch:checked {
background: @bg-selected;
}
.widget-dnd > switch slider {
background: @noti-bg-hover;
border-radius: 12px;
}
/* Label widget */
.widget-label {
margin: 8px;
}
.widget-label > label {
font-size: 1.1rem;
}

View File

@ -28,7 +28,6 @@ namespace SwayNotificationCenter {
});
noti_daemon.on_dnd_toggle.connect ((dnd) => {
noti_daemon.control_center.set_switch_dnd_state (dnd);
try {
subscribe (noti_daemon.control_center.notification_count (),
dnd,
@ -152,6 +151,7 @@ namespace SwayNotificationCenter {
/** Reloads the config file */
public void reload_config () throws Error {
ConfigModel.reload_config ();
noti_daemon.control_center.add_widgets ();
}
/**