diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 73f190829..8a2f8a671 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -370,6 +370,9 @@
/modules/services/trayer.nix @AndreasMager
/tests/modules/services/trayer @AndreasMager
+/modules/services/twmn.nix @Austreelis
+/tests/modules/services/twmn @Austreelis
+
/modules/services/udiskie.nix @rycee
/modules/services/unison.nix @pacien
diff --git a/modules/lib/maintainers.nix b/modules/lib/maintainers.nix
index f912bbd5f..13bdb1e0f 100644
--- a/modules/lib/maintainers.nix
+++ b/modules/lib/maintainers.nix
@@ -13,6 +13,12 @@
github = "amesgen";
githubId = 15369874;
};
+ austreelis = {
+ email = "github@accounts.austreelis.net";
+ github = "Austreelis";
+ githubId = 56743515;
+ name = "Morgane Austreelis";
+ };
CarlosLoboxyz = {
name = "Carlos Lobo";
email = "86011416+CarlosLoboxyz@users.noreply.github.com";
diff --git a/modules/misc/news.nix b/modules/misc/news.nix
index b5678546f..41fe07cee 100644
--- a/modules/misc/news.nix
+++ b/modules/misc/news.nix
@@ -2395,6 +2395,14 @@ in
A new module is available: 'programs.kodi'.
'';
}
+
+ {
+ time = "2022-02-03T23:23:49+00:00";
+ condition = hostPlatform.isLinux;
+ message = ''
+ A new module is available: 'services.twmn'.
+ '';
+ }
];
};
}
diff --git a/modules/modules.nix b/modules/modules.nix
index b52a7aac8..528734dc6 100644
--- a/modules/modules.nix
+++ b/modules/modules.nix
@@ -236,6 +236,7 @@ let
./services/tahoe-lafs.nix
./services/taskwarrior-sync.nix
./services/trayer.nix
+ ./services/twmn.nix
./services/udiskie.nix
./services/unclutter.nix
./services/unison.nix
diff --git a/modules/services/twmn.nix b/modules/services/twmn.nix
new file mode 100644
index 000000000..d4c126829
--- /dev/null
+++ b/modules/services/twmn.nix
@@ -0,0 +1,380 @@
+{ config, lib, pkgs, stdenv, ... }:
+
+with lib;
+
+let
+
+ cfg = config.services.twmn;
+
+ animationOpts = {
+ curve = mkOption {
+ type = types.ints.between 0 40;
+ default = 38;
+ example = 19;
+ description = ''
+ The qt easing-curve animation to use for the animation. See
+
+ QEasingCurve documentation.
+ '';
+ };
+
+ duration = mkOption {
+ type = types.ints.unsigned;
+ default = 1000;
+ example = 618;
+ description = "The animation duration in milliseconds.";
+ };
+ };
+
+in {
+ meta.maintainers = [ hm.maintainers.austreelis ];
+
+ options.services.twmn = {
+ enable = mkEnableOption "twmn, a tiling window manager notification daemon";
+
+ duration = mkOption {
+ type = types.ints.unsigned;
+ default = 3000;
+ example = 5000;
+ description = ''
+ The time each notification remains visible, in milliseconds.
+ '';
+ };
+
+ extraConfig = mkOption {
+ type = types.attrs;
+ default = { };
+ example = literalExpression
+ ''{ main.activation_command = "\${pkgs.hello}/bin/hello"; }'';
+ description = ''
+ Extra configuration options to add to the twmnd config file. See
+
+ for details.
+ '';
+ };
+
+ host = mkOption {
+ type = types.str;
+ default = "127.0.0.1";
+ example = "laptop.lan";
+ description = "Host address to listen on for notifications.";
+ };
+
+ icons = {
+ critical = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = "Path to the critical notifications' icon.";
+ };
+
+ info = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = "Path to the informative notifications' icon.";
+ };
+
+ warning = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = "Path to the warning notifications' icon.";
+ };
+ };
+
+ port = mkOption {
+ type = types.port;
+ default = 9797;
+ description = "UDP port to listen on for notifications.";
+ };
+
+ screen = mkOption {
+ type = types.nullOr types.int;
+ default = null;
+ example = 0;
+ description = ''
+ Screen number to display notifications on when using a multi-head
+ desktop.
+ '';
+ };
+
+ soundCommand = mkOption {
+ type = types.str;
+ default = "";
+ description = "Command to execute to play a notification's sound.";
+ };
+
+ text = {
+ color = mkOption {
+ type = types.str;
+ default = "#999999";
+ example = "lightgray";
+ description = ''
+ Notification's text color. RGB hex and keywords (e.g. lightgray)
+ are supported.
+ '';
+ };
+
+ font = {
+ package = mkOption {
+ type = types.nullOr types.package;
+ default = null;
+ example = literalExpression "pkgs.dejavu_fonts";
+ description = ''
+ Notification text's font package. If null then
+ the font is assumed to already be available in your profile.
+ '';
+ };
+
+ family = mkOption {
+ type = types.str;
+ default = "Sans";
+ example = "Noto Sans";
+ description = "Notification text's font family.";
+ };
+
+ size = mkOption {
+ type = types.ints.unsigned;
+ default = 13;
+ example = 42;
+ description = "Notification text's font size.";
+ };
+
+ variant = mkOption {
+ # These are the font variant supported by twmn
+ # See https://github.com/sboli/twmn/blob/master/README.md?plain=1#L42
+ type = types.enum [
+ "oblique"
+ "italic"
+ "ultra-light"
+ "light"
+ "medium"
+ "semi-bold"
+ "bold"
+ "ultra-bold"
+ "heavy"
+ "ultra-condensed"
+ "extra-condensed"
+ "condensed"
+ "semi-condensed"
+ "semi-expanded"
+ "expanded"
+ "extra-expanded"
+ "ultra-expanded"
+ ];
+ default = "medium";
+ example = "heavy";
+ description = "Notification text's font variant.";
+ };
+ };
+
+ maxLength = mkOption {
+ type = types.nullOr types.ints.unsigned;
+ default = null;
+ example = 80;
+ description = ''
+ Maximum length of the text before it is cut and suffixed with "...".
+ Never cuts if null.
+ '';
+ };
+ };
+
+ window = {
+ alwaysOnTop =
+ mkEnableOption "forcing the notification window to always be on top";
+
+ animation = {
+ easeIn = mkOption {
+ type = types.submodule { options = animationOpts; };
+ default = { };
+ example = literalExpression ''
+ {
+ curve = 19;
+ duration = 618;
+ }
+ '';
+ description = "Options for the notification appearance's animation.";
+ };
+
+ easeOut = mkOption {
+ type = types.submodule { options = animationOpts; };
+ default = { };
+ example = literalExpression ''
+ {
+ curve = 19;
+ duration = 618;
+ }
+ '';
+ description =
+ "Options for the notification disappearance's animation.";
+ };
+
+ bounce = {
+ enable = mkEnableOption
+ "notification bounce when displaying next notification directly.";
+
+ duration = mkOption {
+ type = types.ints.unsigned;
+ default = 500;
+ example = 618;
+ description = "The bounce animation duration in milliseconds.";
+ };
+ };
+ };
+
+ color = mkOption {
+ type = types.str;
+ default = "#000000";
+ example = "lightgray";
+ description = ''
+ Notification's background color. RGB hex and keywords (e.g.
+ lightgray) are supported.
+ '';
+ };
+
+ height = mkOption {
+ type = types.ints.unsigned;
+ default = 18;
+ example = 42;
+ description = ''
+ Height of the slide bar. Useful to match your tiling window
+ manager's bar.
+ '';
+ };
+
+ offset = {
+ x = mkOption {
+ type = types.int;
+ default = 0;
+ example = 50;
+ description = ''
+ Offset of the notification's slide starting point in pixels on the
+ horizontal axis (positive is rightward).
+ '';
+ };
+
+ y = mkOption {
+ type = types.int;
+ default = 0;
+ example = -100;
+ description = ''
+ Offset of the notification's slide starting point in pixels on the
+ vertical axis (positive is upward).
+ '';
+ };
+ };
+
+ opacity = mkOption {
+ type = types.ints.between 0 100;
+ default = 100;
+ example = 80;
+ description = "The notification window's opacity.";
+ };
+
+ position = mkOption {
+ type = types.enum [
+ "tr"
+ "top_right"
+ "tl"
+ "top_left"
+ "br"
+ "bottom_right"
+ "bl"
+ "bottom_left"
+ "tc"
+ "top_center"
+ "bc"
+ "bottom_center"
+ "c"
+ "center"
+ ];
+ default = "top_right";
+ example = "bottom_left";
+ description = ''
+ Position of the notification slide. The notification will slide
+ in vertically from the border if placed in
+ top_center or bottom_center,
+ horizontally otherwise.
+ '';
+ };
+ };
+ };
+
+ #################
+ # Implementation
+
+ config = mkIf cfg.enable {
+ assertions = [
+ (lib.hm.assertions.assertPlatform "services.twmn" pkgs
+ lib.platforms.linux)
+ ];
+
+ home.packages =
+ lib.optional (!isNull cfg.text.font.package) cfg.text.font.package
+ ++ [ pkgs.twmn ];
+
+ xdg.configFile."twmn/twmn.conf".text = let
+ conf = recursiveUpdate {
+ gui = {
+ always_on_top = if cfg.window.alwaysOnTop then "true" else "false";
+ background_color = cfg.window.color;
+ bounce =
+ if cfg.window.animation.bounce.enable then "true" else "false";
+ bounce_duration = toString cfg.window.animation.bounce.duration;
+ font = cfg.text.font.family;
+ font_size = toString cfg.text.font.size;
+ font_variant = cfg.text.font.variant;
+ foreground_color = cfg.text.color;
+ height = toString cfg.window.height;
+ in_animation = toString cfg.window.animation.easeIn.curve;
+ in_animation_duration = toString cfg.window.animation.easeIn.duration;
+ max_length = toString
+ (if isNull cfg.text.maxLength then -1 else cfg.text.maxLength);
+ offset_x = with cfg.window.offset;
+ if x < 0 then toString x else "+${toString x}";
+ offset_y = with cfg.window.offset;
+ if y < 0 then toString y else "+${toString y}";
+ opacity = toString cfg.window.opacity;
+ out_animation = toString cfg.window.animation.easeOut.curve;
+ out_animation_duration =
+ toString cfg.window.animation.easeOut.duration;
+ position = cfg.window.position;
+ screen = toString cfg.screen;
+ };
+ # map null values to empty strings because formats.toml generator fails
+ # when encountering a null.
+ icons = mapAttrs (_: toString) cfg.icons;
+ main = {
+ duration = toString cfg.duration;
+ host = cfg.host;
+ port = toString cfg.port;
+ sound_command = cfg.soundCommand;
+ };
+ } cfg.extraConfig;
+
+ mkLine = name: value: "${name}=${value}";
+
+ mkSection = section: conf: ''
+ [${section}]
+ ${concatStringsSep "\n" (mapAttrsToList mkLine conf)}
+ '';
+ in concatStringsSep "\n" (mapAttrsToList mkSection conf) + "\n";
+
+ systemd.user.services.twmnd = {
+ Unit = {
+ Description = "twmn daemon";
+ After = [ "graphical-session-pre.target" ];
+ PartOf = [ "graphical-session.target" ];
+ X-Restart-Triggers =
+ [ "${config.xdg.configFile."twmn/twmn.conf".source}" ];
+ };
+
+ Install.WantedBy = [ "graphical-session.target" ];
+
+ Service = {
+ ExecStart = "${pkgs.twmn}/bin/twmnd";
+ Restart = "on-failure";
+ Type = "simple";
+ StandardOutput = "null";
+ };
+ };
+ };
+}
diff --git a/tests/default.nix b/tests/default.nix
index da6644f59..73e99639b 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -156,6 +156,7 @@ import nmt {
./modules/services/sxhkd
./modules/services/syncthing
./modules/services/trayer
+ ./modules/services/twmn
./modules/services/window-managers/bspwm
./modules/services/window-managers/herbstluftwm
./modules/services/window-managers/i3
diff --git a/tests/modules/services/twmn/basic-configuration.conf b/tests/modules/services/twmn/basic-configuration.conf
new file mode 100644
index 000000000..a6ddb5c73
--- /dev/null
+++ b/tests/modules/services/twmn/basic-configuration.conf
@@ -0,0 +1,32 @@
+[gui]
+always_on_top=true
+background_color=black
+bounce=true
+bounce_duration=271
+font=Noto Sans
+font_size=16
+font_variant=italic
+foreground_color=#FF00FF
+height=20
+in_animation=27
+in_animation_duration=314
+max_length=80
+offset_x=+20
+offset_y=-60
+opacity=80
+out_animation=13
+out_animation_duration=168
+position=center
+screen=0
+
+[icons]
+critical=/path/icon/critical
+info=/path/icon/info
+warning=/path/icon/warning
+
+[main]
+duration=4242
+host=example.com
+port=9006
+sound_command=/path/sound/command
+
diff --git a/tests/modules/services/twmn/basic-configuration.nix b/tests/modules/services/twmn/basic-configuration.nix
new file mode 100644
index 000000000..5da926e48
--- /dev/null
+++ b/tests/modules/services/twmn/basic-configuration.nix
@@ -0,0 +1,51 @@
+{
+ config = {
+ services.twmn = {
+ enable = true;
+ duration = 4242;
+ host = "example.com";
+ port = 9006;
+ screen = 0;
+ soundCommand = "/path/sound/command";
+ icons.critical = "/path/icon/critical";
+ icons.info = "/path/icon/info";
+ icons.warning = "/path/icon/warning";
+ text = {
+ color = "#FF00FF";
+ font.family = "Noto Sans";
+ font.size = 16;
+ font.variant = "italic";
+ maxLength = 80;
+ };
+ window = {
+ alwaysOnTop = true;
+ color = "black";
+ height = 20;
+ offset.x = 20;
+ offset.y = -60;
+ opacity = 80;
+ position = "center";
+ animation = {
+ easeIn.curve = 27;
+ easeIn.duration = 314;
+ easeOut.curve = 13;
+ easeOut.duration = 168;
+ bounce.enable = true;
+ bounce.duration = 271;
+ };
+ };
+ };
+
+ test.stubs.twmn = { };
+
+ nmt.script = ''
+ serviceFile="home-files/.config/systemd/user/twmnd.service"
+ assertFileExists "$serviceFile"
+ assertFileRegex "$serviceFile" 'X-Restart-Triggers=.*twmn\.conf'
+ assertFileRegex "$serviceFile" 'ExecStart=@twmn@/bin/twmnd'
+ assertFileExists "home-files/.config/twmn/twmn.conf"
+ assertFileContent "home-files/.config/twmn/twmn.conf" \
+ ${./basic-configuration.conf}
+ '';
+ };
+}
diff --git a/tests/modules/services/twmn/default.nix b/tests/modules/services/twmn/default.nix
new file mode 100644
index 000000000..8a021ba38
--- /dev/null
+++ b/tests/modules/services/twmn/default.nix
@@ -0,0 +1 @@
+{ twmn-basic-configuration = ./basic-configuration.nix; }