2351 lines
104 KiB
Nix
2351 lines
104 KiB
Nix
# settings/hyprland.nix
|
|
{ config, pkgs, lib, inputs, ... }:
|
|
let
|
|
hyprland-pkgs = inputs.hyprland.packages.${pkgs.stdenv.hostPlatform.system};
|
|
anyrun-pkgs = inputs.anyrun.packages.${pkgs.stdenv.hostPlatform.system};
|
|
|
|
isMacbook = config.networking.hostName == "FredOS-Macbook";
|
|
isGaming = !isMacbook;
|
|
|
|
in
|
|
{
|
|
config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) {
|
|
programs.hyprland = {
|
|
enable = true;
|
|
xwayland.enable = true;
|
|
package = hyprland-pkgs.hyprland;
|
|
portalPackage = hyprland-pkgs.xdg-desktop-portal-hyprland;
|
|
};
|
|
|
|
xdg.portal = {
|
|
enable = true;
|
|
# xdg-desktop-portal-hyprland is registered automatically by
|
|
# programs.hyprland.portalPackage; listing it here too produced a
|
|
# duplicate user-unit symlink during nixos-rebuild.
|
|
extraPortals = with pkgs; [
|
|
xdg-desktop-portal-gtk
|
|
];
|
|
config.hyprland.default = [ "hyprland" "gtk" ];
|
|
};
|
|
|
|
security.polkit.enable = true;
|
|
|
|
# Polkit GUI agent for GUI sudo prompts under Hyprland
|
|
systemd.user.services.polkit-gnome-authentication-agent-1 = {
|
|
description = "polkit-gnome-authentication-agent-1";
|
|
wantedBy = [ "graphical-session.target" ];
|
|
partOf = [ "graphical-session.target" ];
|
|
after = [ "graphical-session.target" ];
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
ExecStart = "${pkgs.polkit_gnome}/libexec/polkit-gnome-authentication-agent-1";
|
|
Restart = "on-failure";
|
|
};
|
|
};
|
|
|
|
environment.systemPackages = with pkgs; [
|
|
ghostty
|
|
libnotify
|
|
grim
|
|
slurp
|
|
wl-clipboard
|
|
cliphist
|
|
brightnessctl
|
|
swayosd
|
|
playerctl
|
|
hyprpaper
|
|
hyprlock
|
|
hypridle
|
|
hyprshot
|
|
networkmanagerapplet
|
|
pavucontrol
|
|
polkit_gnome
|
|
quickshell
|
|
qt6.qt5compat
|
|
zenity
|
|
libcanberra-gtk3
|
|
];
|
|
|
|
# Use upstream anyrun flake's HM module instead of the built-in one
|
|
# for working daemon mode.
|
|
home-manager.sharedModules = [
|
|
({ modulesPath, ... }: {
|
|
disabledModules = [ "${modulesPath}/programs/anyrun.nix" ];
|
|
})
|
|
inputs.anyrun.homeManagerModules.default
|
|
];
|
|
|
|
home-manager.users.fred = { config, lib, pkgs, inputs, ... }:
|
|
let
|
|
c = config.lib.stylix.colors;
|
|
rgb = hex: "rgb(${hex})";
|
|
rgba = hex: a: "rgba(${hex}${a})";
|
|
in {
|
|
# Stylix's Hyprland target injects settings.{general,decoration,group,misc}
|
|
# as top-level keys, which render as hl.general()/hl.decoration()/… in Lua
|
|
# mode — functions that don't exist. Disable it and absorb the colours
|
|
# into settings.config below.
|
|
stylix.targets.hyprland.enable = false;
|
|
|
|
# The disabled Hyprland target would normally enable this; do it
|
|
# manually. Stylix's hyprpaper target (auto-enabled) still handles
|
|
# preload/wallpaper settings.
|
|
services.hyprpaper.enable = true;
|
|
|
|
wayland.windowManager.hyprland = {
|
|
enable = true;
|
|
configType = "lua";
|
|
systemd.variables = [ "--all" ];
|
|
package = hyprland-pkgs.hyprland;
|
|
|
|
settings = {
|
|
# hl.config({...}) — all static named-section configuration.
|
|
# monitor is set per-host in hosts/FredOS-{Gaming,Macbook}.nix.
|
|
config = {
|
|
general = {
|
|
gaps_in = 6;
|
|
gaps_out = 12;
|
|
border_size = 2;
|
|
layout = "dwindle";
|
|
resize_on_border = true;
|
|
"col.active_border" = rgb c.base0D;
|
|
"col.inactive_border" = rgb c.base03;
|
|
};
|
|
|
|
decoration = {
|
|
rounding = 8;
|
|
blur = {
|
|
enabled = true;
|
|
};
|
|
shadow.color = rgba c.base00 "99";
|
|
};
|
|
|
|
group = {
|
|
"col.border_active" = rgb c.base0D;
|
|
"col.border_inactive" = rgb c.base03;
|
|
"col.border_locked_active" = rgb c.base0C;
|
|
groupbar = {
|
|
text_color = rgb c.base05;
|
|
"col.active" = rgb c.base0D;
|
|
"col.inactive" = rgb c.base03;
|
|
};
|
|
};
|
|
|
|
render = {
|
|
direct_scanout = false;
|
|
};
|
|
|
|
animations = {
|
|
enabled = true;
|
|
};
|
|
|
|
input = {
|
|
kb_layout = "gb,no";
|
|
kb_options = "grp:alt_shift_toggle";
|
|
follow_mouse = 1;
|
|
accel_profile = "flat";
|
|
sensitivity = 0;
|
|
} // lib.optionalAttrs isMacbook {
|
|
touchpad = {
|
|
tap_to_click = true;
|
|
tap_button_map = "lrm";
|
|
natural_scroll = true;
|
|
};
|
|
};
|
|
|
|
cursor = {
|
|
no_warps = true;
|
|
};
|
|
|
|
dwindle = {
|
|
preserve_split = true;
|
|
};
|
|
|
|
misc = {
|
|
disable_hyprland_logo = true;
|
|
disable_splash_rendering = true;
|
|
# Apps demanding attention don't get to yank focus — they'll
|
|
# show as urgent in the bar instead.
|
|
focus_on_activate = false;
|
|
vrr = 2;
|
|
background_color = rgb c.base00;
|
|
};
|
|
|
|
# vfr moved from misc: to debug: in 0.55.0
|
|
debug = {
|
|
vfr = false; # keep compositor ticking, don't idle between frames
|
|
disable_logs = false;
|
|
};
|
|
};
|
|
};
|
|
|
|
extraConfig =
|
|
let
|
|
powerMenu = pkgs.writeShellScript "power-menu" ''
|
|
# Stop the daemon so standalone stdin mode can run cleanly.
|
|
# systemd restarts it automatically afterwards (Restart=on-failure).
|
|
systemctl --user stop anyrun.service 2>/dev/null || true
|
|
choice=$(printf '%s\n' \
|
|
$'\uf023 Lock' \
|
|
$'\uf08b Logout' \
|
|
$'\uf01e Reboot' \
|
|
$'\uf011 Shutdown' \
|
|
| ${anyrun-pkgs.anyrun}/bin/anyrun \
|
|
--plugins "${anyrun-pkgs.stdin}/lib/libstdin.so" \
|
|
--show-results-immediately true \
|
|
--hide-plugin-info true \
|
|
--close-on-click true)
|
|
# Restart the daemon service (reset-failed clears the start-rate limiter).
|
|
systemctl --user reset-failed anyrun.service 2>/dev/null
|
|
systemctl --user start anyrun.service 2>/dev/null
|
|
case "$choice" in
|
|
*Lock) ${pkgs.hyprlock}/bin/hyprlock ;;
|
|
*Logout) hyprctl dispatch exit ;;
|
|
*Reboot) systemctl reboot ;;
|
|
*Shutdown) systemctl poweroff ;;
|
|
esac
|
|
'';
|
|
kbdBrightUp = pkgs.writeShellScript "kbd-bright-up" ''
|
|
${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight set +10%
|
|
brightness=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight get)
|
|
max=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight max)
|
|
echo $(( brightness * 100 / max )) > "$XDG_RUNTIME_DIR/wob.fifo"
|
|
'';
|
|
kbdBrightDown = pkgs.writeShellScript "kbd-bright-down" ''
|
|
${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight set 10%-
|
|
brightness=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight get)
|
|
max=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight max)
|
|
echo $(( brightness * 100 / max )) > "$XDG_RUNTIME_DIR/wob.fifo"
|
|
'';
|
|
in
|
|
''
|
|
-- Environment
|
|
hl.env("XCURSOR_THEME", "Bibata-Modern-Ice")
|
|
hl.env("XCURSOR_SIZE", "24")
|
|
hl.env("HYPRCURSOR_THEME", "Bibata-Modern-Ice")
|
|
hl.env("HYPRCURSOR_SIZE", "24")
|
|
hl.env("ELECTRON_OZONE_PLATFORM_HINT", "wayland")
|
|
hl.env("MOZ_ENABLE_WAYLAND", "1")
|
|
hl.env("QT_QPA_PLATFORM", "wayland;xcb")
|
|
hl.env("SDL_VIDEODRIVER", "wayland")
|
|
hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
|
|
${lib.optionalString isGaming ''
|
|
-- GPU pinning — Navi 22 is card1 on the dual-GPU gaming box.
|
|
hl.env("AQ_DRM_DEVICES", "/dev/dri/card1")
|
|
hl.env("DRI_PRIME", "pci-0000_03_00_0")
|
|
''}
|
|
|
|
-- Startup
|
|
hl.on("hyprland.start", function()
|
|
-- Ensure hyprland-session.target starts even if HM's
|
|
-- dbus-update-activation-environment chain fails upstream.
|
|
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
|
-- mako removed; notifications handled by quickshell
|
|
|
|
hl.exec_cmd("wl-paste --type text --watch cliphist store")
|
|
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
|
hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24")
|
|
hl.exec_cmd("swayosd-server")
|
|
${lib.optionalString isMacbook ''hl.exec_cmd("hypridle")''}
|
|
end)
|
|
|
|
-- Animation curve and definitions
|
|
hl.curve("snap", { type = "bezier", points = { {0.05, 0.9}, {0.1, 1.0} } })
|
|
hl.animation({ leaf = "windows", enabled = true, speed = 1, bezier = "snap" })
|
|
hl.animation({ leaf = "windowsOut", enabled = true, speed = 1, bezier = "snap", style = "popin 80%" })
|
|
hl.animation({ leaf = "layers", enabled = true, speed = 1, bezier = "snap" })
|
|
hl.animation({ leaf = "border", enabled = true, speed = 2, bezier = "default" })
|
|
hl.animation({ leaf = "fade", enabled = true, speed = 1, bezier = "default" })
|
|
hl.animation({ leaf = "workspaces", enabled = true, speed = 1, bezier = "snap" })
|
|
|
|
-- Window rules
|
|
-- Battle.net tray icon leaks as a tiny floating XWayland window.
|
|
hl.window_rule({
|
|
match = { class = "steam_app_0", title = "^$", float = true },
|
|
workspace = "special silent",
|
|
})
|
|
|
|
-- Binds
|
|
local mod = "SUPER"
|
|
|
|
-- Apps
|
|
hl.bind(mod .. " + T", hl.dsp.exec_cmd("ghostty"))
|
|
hl.bind(mod .. " + E", hl.dsp.exec_cmd("nemo"))
|
|
hl.bind(mod .. " + R", hl.dsp.exec_cmd("anyrun close || anyrun"))
|
|
hl.bind(mod .. " + Q", hl.dsp.window.close())
|
|
hl.bind(mod .. " + SHIFT + E", hl.dsp.exit())
|
|
|
|
-- Floating / layout
|
|
hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" }))
|
|
hl.bind(mod .. " + F", hl.dsp.window.fullscreen())
|
|
hl.bind(mod .. " + P", hl.dsp.window.pseudo())
|
|
hl.bind(mod .. " + S", hl.dsp.layout("togglesplit"))
|
|
|
|
-- Focus
|
|
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" }))
|
|
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" }))
|
|
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" }))
|
|
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" }))
|
|
hl.bind(mod .. " + H", hl.dsp.focus({ direction = "left" }))
|
|
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "up" }))
|
|
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "down" }))
|
|
|
|
-- Power menu — dismiss launcher if open, then show menu
|
|
hl.bind(mod .. " + L", hl.dsp.exec_cmd("anyrun close 2>/dev/null; ${powerMenu}"))
|
|
|
|
-- Move windows
|
|
hl.bind(mod .. " + SHIFT + left", hl.dsp.window.move({ direction = "left" }))
|
|
hl.bind(mod .. " + SHIFT + right", hl.dsp.window.move({ direction = "right" }))
|
|
hl.bind(mod .. " + SHIFT + up", hl.dsp.window.move({ direction = "up" }))
|
|
hl.bind(mod .. " + SHIFT + down", hl.dsp.window.move({ direction = "down" }))
|
|
|
|
-- Workspaces
|
|
for i = 0, 9 do
|
|
local workspace_id = tostring((i == 0) and 10 or i)
|
|
hl.bind(mod .. " + " .. i, hl.dsp.focus({ workspace = workspace_id }))
|
|
hl.bind(mod .. " + SHIFT + " .. i, hl.dsp.window.move({ workspace = workspace_id, follow = false }))
|
|
end
|
|
|
|
-- Screenshots — Shift+Super+S matches GNOME binding
|
|
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("hyprshot -m region --clipboard-only"))
|
|
hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only"))
|
|
|
|
-- Settings shortcut — Super+I matches GNOME binding
|
|
hl.bind(mod .. " + I", hl.dsp.exec_cmd("pavucontrol"))
|
|
|
|
-- Custom shortcuts
|
|
hl.bind(mod .. " + Z", hl.dsp.exec_cmd("zen-beta"))
|
|
|
|
-- Mouse window manipulation
|
|
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
|
|
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
|
|
|
|
-- Volume / brightness (repeating)
|
|
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("swayosd-client --output-volume raise"), { repeating = true })
|
|
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("swayosd-client --output-volume lower"), { repeating = true })
|
|
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("swayosd-client --output-volume mute-toggle"), { repeating = true })
|
|
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("swayosd-client --brightness raise"), { repeating = true })
|
|
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("swayosd-client --brightness lower"), { repeating = true })
|
|
${lib.optionalString isMacbook ''
|
|
hl.bind("XF86KbdBrightnessUp", hl.dsp.exec_cmd("${kbdBrightUp}"), { repeating = true })
|
|
hl.bind("XF86KbdBrightnessDown", hl.dsp.exec_cmd("${kbdBrightDown}"), { repeating = true })
|
|
''}
|
|
|
|
-- Media keys (locked — work through lockscreen)
|
|
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
|
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
|
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
|
'';
|
|
};
|
|
|
|
programs.anyrun = {
|
|
enable = true;
|
|
config = {
|
|
plugins = [ anyrun-pkgs.applications ];
|
|
x.fraction = 0.5;
|
|
y.fraction = 0.25;
|
|
width.absolute = 350;
|
|
height.absolute = 0;
|
|
hideIcons = false;
|
|
ignoreExclusiveZones = false;
|
|
layer = "overlay";
|
|
hidePluginInfo = true;
|
|
closeOnClick = true;
|
|
maxEntries = 8;
|
|
};
|
|
extraCss =
|
|
let c = config.lib.stylix.colors; in
|
|
''
|
|
* { all: unset; font-family: "FiraMono Nerd Font", monospace; font-size: 13px; }
|
|
window { background: transparent; }
|
|
box.main {
|
|
background: #${c.base00};
|
|
border: 1px solid #${c.base03};
|
|
border-radius: 10px;
|
|
padding: 8px;
|
|
margin: 16px;
|
|
}
|
|
text {
|
|
background: #${c.base01};
|
|
color: #${c.base05};
|
|
caret-color: #${c.base0D};
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
min-height: 0;
|
|
}
|
|
list.plugin { background: transparent; }
|
|
.matches { background: transparent; }
|
|
.match {
|
|
padding: 4px 16px;
|
|
border-radius: 6px;
|
|
color: #${c.base05};
|
|
background: transparent;
|
|
}
|
|
.match:selected {
|
|
background: #${c.base02};
|
|
border: none;
|
|
}
|
|
label.match.description { color: #${c.base04}; font-size: 11px; }
|
|
'';
|
|
extraConfigFiles."applications.ron".text = ''
|
|
Config(
|
|
desktop_actions: false,
|
|
max_entries: 8,
|
|
terminal: Some("ghostty"),
|
|
)
|
|
'';
|
|
};
|
|
|
|
programs.hyprlock = {
|
|
enable = true;
|
|
settings = {
|
|
general = {
|
|
grace = 5;
|
|
hide_cursor = true;
|
|
};
|
|
background = {
|
|
blur_passes = 3;
|
|
blur_size = 8;
|
|
brightness = 0.6;
|
|
};
|
|
input-field = {
|
|
size = "280, 44";
|
|
outline_thickness = 2;
|
|
dots_size = 0.25;
|
|
dots_spacing = 0.2;
|
|
dots_center = true;
|
|
fade_on_empty = true;
|
|
fade_timeout = 3000;
|
|
placeholder_text = "";
|
|
fail_text = "";
|
|
position = "0, -40";
|
|
halign = "center";
|
|
valign = "center";
|
|
rounding = 8;
|
|
};
|
|
label = [
|
|
{
|
|
text = "$TIME";
|
|
color = "rgb(${c.base05})";
|
|
font_size = 72;
|
|
font_family = "Inter";
|
|
position = "0, 120";
|
|
halign = "center";
|
|
valign = "center";
|
|
}
|
|
{
|
|
text = "cmd[update:60000] date +'%A, %d %B'";
|
|
color = "rgb(${c.base04})";
|
|
font_size = 16;
|
|
font_family = "Inter";
|
|
position = "0, 60";
|
|
halign = "center";
|
|
valign = "center";
|
|
}
|
|
];
|
|
};
|
|
};
|
|
|
|
services.hypridle = lib.mkIf isMacbook {
|
|
enable = true;
|
|
settings = {
|
|
general = {
|
|
lock_cmd = "pidof hyprlock || hyprlock";
|
|
before_sleep_cmd = "loginctl lock-session";
|
|
after_sleep_cmd = "hyprctl dispatch dpms on";
|
|
};
|
|
listener = [
|
|
{
|
|
timeout = 300; # 5 min — lock
|
|
on-timeout = "loginctl lock-session";
|
|
}
|
|
{
|
|
timeout = 420; # 7 min — display off
|
|
on-timeout = "hyprctl dispatch dpms off";
|
|
on-resume = "hyprctl dispatch dpms on";
|
|
}
|
|
{
|
|
timeout = 600; # 10 min — suspend
|
|
on-timeout = "systemctl suspend";
|
|
}
|
|
];
|
|
};
|
|
};
|
|
|
|
# Scope all HM Wayland services (hyprpaper, etc.) to the
|
|
# Hyprland session so they don't crash-loop in a GNOME session.
|
|
wayland.systemd.target = "hyprland-session.target";
|
|
|
|
systemd.user.services.quickshell = {
|
|
Unit = {
|
|
Description = "Quickshell desktop shell";
|
|
PartOf = [ "graphical-session.target" ];
|
|
After = [ "graphical-session.target" ];
|
|
};
|
|
Service = {
|
|
ExecStart = "${pkgs.quickshell}/bin/qs";
|
|
Restart = "always";
|
|
RestartSec = 2;
|
|
};
|
|
Install.WantedBy = [ "hyprland-session.target" ];
|
|
};
|
|
|
|
xdg.configFile = let
|
|
qsRestart = ''
|
|
${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true
|
|
'';
|
|
wifiConnectScript = pkgs.writeShellScript "wifi-connect" ''
|
|
ssid="$1"
|
|
${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" 2>/dev/null && exit 0
|
|
pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null)
|
|
[ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw"
|
|
'';
|
|
nmcli = "${pkgs.networkmanager}/bin/nmcli";
|
|
powerprofilesctl = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl";
|
|
in {
|
|
"quickshell/qmldir" = {
|
|
onChange = qsRestart;
|
|
text = ''
|
|
singleton Theme 1.0 Theme.qml
|
|
singleton Commands 1.0 Commands.qml
|
|
Bar 1.0 Bar.qml
|
|
NotificationToast 1.0 NotificationToast.qml
|
|
'';
|
|
};
|
|
|
|
"quickshell/Theme.qml" = {
|
|
onChange = qsRestart;
|
|
text = ''
|
|
pragma Singleton
|
|
import QtQuick
|
|
|
|
QtObject {
|
|
readonly property color base00: "#${c.base00}"
|
|
readonly property color base01: "#${c.base01}"
|
|
readonly property color base02: "#${c.base02}"
|
|
readonly property color base03: "#${c.base03}"
|
|
readonly property color base04: "#${c.base04}"
|
|
readonly property color base05: "#${c.base05}"
|
|
readonly property color base08: "#${c.base08}"
|
|
readonly property color base0A: "#${c.base0A}"
|
|
readonly property color base0B: "#${c.base0B}"
|
|
readonly property color base0C: "#${c.base0C}"
|
|
readonly property color base0D: "#${c.base0D}"
|
|
readonly property color barBg: "#D1${c.base00}"
|
|
readonly property color toastBg: "#E6${c.base00}"
|
|
}
|
|
'';
|
|
};
|
|
|
|
"quickshell/Commands.qml" = {
|
|
onChange = qsRestart;
|
|
text = ''
|
|
pragma Singleton
|
|
import QtQuick
|
|
|
|
QtObject {
|
|
readonly property string nmcli: "${nmcli}"
|
|
readonly property string wifiConnect: "${wifiConnectScript}"
|
|
readonly property string powerprofilesctl: "${powerprofilesctl}"
|
|
readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play"
|
|
}
|
|
'';
|
|
};
|
|
|
|
"quickshell/shell.qml" = {
|
|
onChange = qsRestart;
|
|
text = ''
|
|
//@ pragma UseQApplication
|
|
import Quickshell
|
|
import Quickshell.Services.Notifications
|
|
import QtQuick
|
|
|
|
ShellRoot {
|
|
id: root
|
|
property var latestNotification: null
|
|
signal notificationReceived()
|
|
|
|
NotificationServer {
|
|
id: _notifServer
|
|
bodySupported: true
|
|
actionsSupported: true
|
|
imageSupported: true
|
|
persistenceSupported: true
|
|
keepOnReload: true
|
|
onNotification: (notification) => {
|
|
notification.tracked = true;
|
|
root.latestNotification = notification;
|
|
root.notificationReceived();
|
|
}
|
|
}
|
|
|
|
Variants {
|
|
model: Quickshell.screens
|
|
|
|
Bar {
|
|
notifServer: _notifServer
|
|
}
|
|
}
|
|
|
|
NotificationToast {
|
|
shellRoot: root
|
|
}
|
|
}
|
|
'';
|
|
};
|
|
|
|
"quickshell/Bar.qml" = {
|
|
onChange = qsRestart;
|
|
text = ''
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import Quickshell.Services.SystemTray
|
|
import Quickshell.Services.Notifications
|
|
import Quickshell.Services.Pipewire
|
|
import Quickshell.Widgets
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Qt5Compat.GraphicalEffects
|
|
|
|
PanelWindow {
|
|
id: bar
|
|
required property var modelData
|
|
required property NotificationServer notifServer
|
|
screen: modelData
|
|
|
|
anchors {
|
|
top: true
|
|
left: true
|
|
right: true
|
|
}
|
|
|
|
implicitHeight: 30
|
|
color: Theme.barBg
|
|
|
|
property var activeDropdown: null
|
|
|
|
function closeAllDropdowns() {
|
|
if (activeDropdown && activeDropdown.visible) {
|
|
activeDropdown.animateClose();
|
|
}
|
|
activeDropdown = null;
|
|
}
|
|
function toggleDropdown(dd, setupFn) {
|
|
if (dd.visible && !dd.closing) {
|
|
dd.animateClose();
|
|
activeDropdown = null;
|
|
} else {
|
|
if (activeDropdown && activeDropdown !== dd && activeDropdown.visible) {
|
|
activeDropdown.animateClose();
|
|
}
|
|
if (setupFn) setupFn();
|
|
if (dd.closing) {
|
|
dd.closing = false;
|
|
dd.open = true;
|
|
} else {
|
|
dd.visible = true;
|
|
}
|
|
activeDropdown = dd;
|
|
}
|
|
}
|
|
|
|
// Left — workspaces
|
|
Row {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 0
|
|
|
|
Repeater {
|
|
model: Hyprland.workspaces
|
|
|
|
Item {
|
|
required property var modelData
|
|
width: 28
|
|
height: 30
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: modelData.name
|
|
color: modelData.focused ? Theme.base05 : Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.bottom: parent.bottom
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: parent.width - 8
|
|
height: 2
|
|
color: Theme.base05
|
|
visible: modelData.focused
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: modelData.activate()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Center — clock
|
|
Text {
|
|
id: clockText
|
|
anchors.centerIn: parent
|
|
property date now: new Date()
|
|
text: now.toLocaleTimeString(Qt.locale(), "HH:mm")
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
font.weight: Font.Medium
|
|
|
|
Timer {
|
|
interval: 1000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: clockText.now = new Date()
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: bar.toggleDropdown(calPopup)
|
|
onEntered: {
|
|
if (bar.activeDropdown) {
|
|
if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup);
|
|
else bar.activeDropdown.resetAutoClose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right — network, battery, tray
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 10
|
|
|
|
// Volume
|
|
Item {
|
|
id: volWidget
|
|
width: volText.width
|
|
height: 30
|
|
|
|
property PwNode sink: Pipewire.defaultAudioSink
|
|
|
|
PwObjectTracker {
|
|
objects: [volWidget.sink]
|
|
}
|
|
|
|
property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0
|
|
property bool muted: sink && sink.audio ? sink.audio.muted : false
|
|
property string volIcon: muted ? "\u{f0581}"
|
|
: vol > 66 ? "\u{f057e}"
|
|
: vol > 33 ? "\u{f0580}"
|
|
: vol > 0 ? "\u{f057f}"
|
|
: "\u{f0581}"
|
|
|
|
function openVolDropdown() {
|
|
bar.toggleDropdown(volDropdown, function() {
|
|
let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0);
|
|
volDropdown.dropdownX = pos.x;
|
|
});
|
|
}
|
|
|
|
Text {
|
|
id: volText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: volWidget.volIcon + " " + volWidget.vol + "%"
|
|
color: volWidget.muted ? Theme.base03 : Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
|
onClicked: (event) => {
|
|
if (event.button === Qt.MiddleButton) {
|
|
if (volWidget.sink && volWidget.sink.audio)
|
|
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
|
|
} else {
|
|
volWidget.openVolDropdown();
|
|
}
|
|
}
|
|
onEntered: {
|
|
if (bar.activeDropdown) {
|
|
if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown();
|
|
else bar.activeDropdown.resetAutoClose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Network status
|
|
Item {
|
|
id: netWidget
|
|
width: 16
|
|
height: 30
|
|
|
|
property string netState: "disconnected"
|
|
property string netConn: ""
|
|
property string netType: ""
|
|
property string netIcon: "\u{f0b0}"
|
|
property var wifiNetworks: []
|
|
property string netDevice: ""
|
|
|
|
property string _pendingState: "disconnected"
|
|
property string _pendingConn: ""
|
|
property string _pendingType: ""
|
|
property string _pendingDevice: ""
|
|
property var _pendingNets: []
|
|
|
|
Timer {
|
|
interval: 5000
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: netWidget.refreshNet()
|
|
}
|
|
|
|
function refreshNet() {
|
|
netWidget._pendingState = "disconnected";
|
|
netWidget._pendingConn = "";
|
|
netWidget._pendingType = "";
|
|
netProc.running = true;
|
|
}
|
|
|
|
Process {
|
|
id: netProc
|
|
command: [Commands.nmcli, "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"]
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
let fields = data.split(":");
|
|
if (fields.length < 4) return;
|
|
let type = fields[1];
|
|
let state = fields[2];
|
|
let conn = fields[3];
|
|
if (type !== "ethernet" && type !== "wifi") return;
|
|
if (type === "wifi") {
|
|
netWidget._pendingDevice = fields[0];
|
|
}
|
|
if (state === "connected") {
|
|
netWidget._pendingState = "connected";
|
|
netWidget._pendingConn = conn;
|
|
netWidget._pendingType = type;
|
|
}
|
|
}
|
|
}
|
|
onRunningChanged: {
|
|
if (!running) {
|
|
netWidget.netState = netWidget._pendingState;
|
|
netWidget.netConn = netWidget._pendingConn;
|
|
netWidget.netType = netWidget._pendingType.length > 0 ? netWidget._pendingType : netWidget.netType;
|
|
netWidget.netDevice = netWidget._pendingDevice.length > 0 ? netWidget._pendingDevice : netWidget.netDevice;
|
|
if (netWidget.netState === "connected") {
|
|
netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05a9}" : "\u{f0200}";
|
|
} else {
|
|
netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05aa}" : "\u{f0201}";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: netWidget.netIcon
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 14
|
|
}
|
|
|
|
Timer {
|
|
id: netRefreshDelay
|
|
interval: 2000
|
|
onTriggered: netWidget.refreshNet()
|
|
}
|
|
|
|
Process {
|
|
id: wifiScanProc
|
|
command: [Commands.nmcli, "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "device", "wifi", "list", "--rescan", "auto"]
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
let fields = data.split(":");
|
|
if (fields.length < 4 || fields[0] === "") return;
|
|
for (let i = 0; i < netWidget._pendingNets.length; i++) {
|
|
if (netWidget._pendingNets[i].ssid === fields[0]) return;
|
|
}
|
|
netWidget._pendingNets.push({
|
|
ssid: fields[0],
|
|
signal: parseInt(fields[1]) || 0,
|
|
security: fields[2],
|
|
active: fields[3] === "*"
|
|
});
|
|
}
|
|
}
|
|
onRunningChanged: {
|
|
if (!running) {
|
|
netWidget.wifiNetworks = netWidget._pendingNets;
|
|
netWidget._pendingNets = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: wifiConnectProc
|
|
property string targetSsid: ""
|
|
command: [Commands.wifiConnect, targetSsid]
|
|
}
|
|
|
|
Process {
|
|
id: netDisconnectProc
|
|
property string targetDevice: ""
|
|
command: [Commands.nmcli, "device", "disconnect", targetDevice]
|
|
}
|
|
|
|
function openNetDropdown() {
|
|
bar.toggleDropdown(netDropdown, function() {
|
|
wifiScanProc.running = true;
|
|
let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0);
|
|
netDropdown.dropdownX = pos.x;
|
|
});
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: netWidget.openNetDropdown()
|
|
onEntered: {
|
|
if (bar.activeDropdown) {
|
|
if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown();
|
|
else bar.activeDropdown.resetAutoClose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
${lib.optionalString isMacbook ''
|
|
// Battery
|
|
Item {
|
|
id: batteryWidget
|
|
width: batteryText.width + 4 + batteryIconText.width
|
|
height: 30
|
|
|
|
property int batteryLevel: 0
|
|
property bool charging: false
|
|
property string batteryIcon: "\u{f008e}"
|
|
property real powerDraw: 0.0
|
|
property real energyNow: 0.0
|
|
property real energyFull: 0.0
|
|
property string timeRemaining: ""
|
|
property string powerProfile: "balanced"
|
|
|
|
function updateIcon() {
|
|
if (charging) { batteryIcon = "\u{f0084}"; return; }
|
|
if (batteryLevel >= 90) batteryIcon = "\u{f0079}";
|
|
else if (batteryLevel >= 70) batteryIcon = "\u{f0082}";
|
|
else if (batteryLevel >= 50) batteryIcon = "\u{f007f}";
|
|
else if (batteryLevel >= 30) batteryIcon = "\u{f007c}";
|
|
else if (batteryLevel >= 15) batteryIcon = "\u{f007a}";
|
|
else batteryIcon = "\u{f008e}";
|
|
}
|
|
|
|
Timer {
|
|
interval: 5000
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: { batteryProc.running = true; profileProc.running = true; }
|
|
}
|
|
|
|
Process {
|
|
id: batteryProc
|
|
command: ["sh", "-c", "cat /sys/class/power_supply/BAT0/capacity; cat /sys/class/power_supply/BAT0/status; cat /sys/class/power_supply/BAT0/power_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_full 2>/dev/null || echo 0"]
|
|
stdout: SplitParser {
|
|
property int lineNum: 0
|
|
onRead: data => {
|
|
let trimmed = data.trim();
|
|
let num = parseInt(trimmed);
|
|
lineNum++;
|
|
if (lineNum === 1) {
|
|
if (!isNaN(num)) batteryWidget.batteryLevel = num;
|
|
} else if (lineNum === 2) {
|
|
batteryWidget.charging = (trimmed === "Charging");
|
|
} else if (lineNum === 3) {
|
|
if (!isNaN(num)) batteryWidget.powerDraw = num / 1000000.0;
|
|
} else if (lineNum === 4) {
|
|
if (!isNaN(num)) batteryWidget.energyNow = num / 1000000.0;
|
|
} else if (lineNum === 5) {
|
|
if (!isNaN(num)) batteryWidget.energyFull = num / 1000000.0;
|
|
lineNum = 0;
|
|
if (batteryWidget.powerDraw > 0.5) {
|
|
let hours;
|
|
if (batteryWidget.charging) {
|
|
hours = (batteryWidget.energyFull - batteryWidget.energyNow) / batteryWidget.powerDraw;
|
|
} else {
|
|
hours = batteryWidget.energyNow / batteryWidget.powerDraw;
|
|
}
|
|
let h = Math.floor(hours);
|
|
let m = Math.round((hours - h) * 60);
|
|
batteryWidget.timeRemaining = h + "h " + m + "m";
|
|
} else {
|
|
batteryWidget.timeRemaining = "";
|
|
}
|
|
}
|
|
batteryWidget.updateIcon();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: profileProc
|
|
command: [Commands.powerprofilesctl, "get"]
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
batteryWidget.powerProfile = data.trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: setProfileProc
|
|
property string target: "balanced"
|
|
command: [Commands.powerprofilesctl, "set", target]
|
|
}
|
|
|
|
Row {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 4
|
|
|
|
Text {
|
|
id: batteryText
|
|
text: batteryWidget.batteryLevel + "%"
|
|
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
|
|
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
|
|
: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
}
|
|
|
|
Text {
|
|
id: batteryIconText
|
|
text: batteryWidget.batteryIcon
|
|
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
|
|
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
|
|
: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 14
|
|
}
|
|
}
|
|
|
|
function openBatteryDropdown() {
|
|
bar.toggleDropdown(batteryDropdown, function() {
|
|
batteryProc.running = true;
|
|
profileProc.running = true;
|
|
let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0);
|
|
batteryDropdown.dropdownX = pos.x;
|
|
});
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: batteryWidget.openBatteryDropdown()
|
|
onEntered: {
|
|
if (bar.activeDropdown) {
|
|
if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown();
|
|
else bar.activeDropdown.resetAutoClose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
''}
|
|
|
|
// Tray icons
|
|
Row {
|
|
id: trayArea
|
|
spacing: 8
|
|
height: 30
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
HoverHandler {
|
|
onHoveredChanged: {
|
|
if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose();
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: SystemTray.items
|
|
|
|
Item {
|
|
required property var modelData
|
|
width: 24
|
|
height: 30
|
|
|
|
Image {
|
|
id: trayIcon
|
|
anchors.centerIn: parent
|
|
width: 16
|
|
height: 16
|
|
source: modelData.icon
|
|
sourceSize.width: 16
|
|
sourceSize.height: 16
|
|
smooth: true
|
|
mipmap: true
|
|
visible: false
|
|
}
|
|
|
|
ColorOverlay {
|
|
anchors.fill: trayIcon
|
|
source: trayIcon
|
|
color: Theme.base05
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.NoButton
|
|
onEntered: {
|
|
if (bar.activeDropdown) {
|
|
bar.activeDropdown.resetAutoClose();
|
|
if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) {
|
|
if (bar.activeDropdown === contextMenu) {
|
|
// Same dropdown, just switch content
|
|
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
|
|
contextMenu.dropdownX = pos.x;
|
|
contextMenu.trayItem = modelData;
|
|
menuOpener.menu = modelData.menu;
|
|
contextMenu.resetAutoClose();
|
|
} else {
|
|
bar.toggleDropdown(contextMenu, function() {
|
|
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
|
|
contextMenu.dropdownX = pos.x;
|
|
contextMenu.trayItem = modelData;
|
|
menuOpener.menu = modelData.menu;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
onClicked: (event) => {
|
|
if (modelData.hasMenu) {
|
|
bar.toggleDropdown(contextMenu, function() {
|
|
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
|
|
contextMenu.dropdownX = pos.x;
|
|
contextMenu.trayItem = modelData;
|
|
menuOpener.menu = modelData.menu;
|
|
});
|
|
} else {
|
|
modelData.activate();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reusable dropdown component
|
|
component BarDropdown: PopupWindow {
|
|
id: dropdown
|
|
property bool open: false
|
|
property bool closing: false
|
|
property real dropdownX: 0
|
|
property real fullWidth: 200
|
|
property real fullHeight: 200
|
|
property int autoCloseMs: 1500
|
|
property bool alignRight: false
|
|
default property alias content: dropdownContent.data
|
|
|
|
function animateClose() {
|
|
if (!visible || closing) return;
|
|
closing = true;
|
|
open = false;
|
|
_autoClose.stop();
|
|
_closeDelay.start();
|
|
}
|
|
|
|
function resetAutoClose() {
|
|
if (visible && !closing) _autoClose.restart();
|
|
}
|
|
|
|
anchor.window: bar
|
|
anchor.rect.x: alignRight ? bar.width - fullWidth - 8 : dropdownX - (fullWidth + 16) / 2
|
|
anchor.rect.y: bar.height
|
|
anchor.edges: Edges.Top | Edges.Left
|
|
anchor.gravity: Edges.Bottom | Edges.Right
|
|
anchor.adjustment: alignRight ? PopupAdjustment.None : PopupAdjustment.Slide
|
|
visible: false
|
|
color: "transparent"
|
|
implicitWidth: fullWidth + (alignRight ? 8 : 16)
|
|
implicitHeight: fullHeight + 4 + (alignRight ? 8 : 0)
|
|
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
closing = false;
|
|
open = true;
|
|
_autoClose.restart();
|
|
} else {
|
|
open = false;
|
|
closing = false;
|
|
_autoClose.stop();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: _autoClose
|
|
interval: dropdown.autoCloseMs
|
|
onTriggered: bar.closeAllDropdowns()
|
|
}
|
|
|
|
Timer {
|
|
id: _closeDelay
|
|
interval: 230
|
|
onTriggered: { dropdown.visible = false; dropdown.closing = false; }
|
|
}
|
|
|
|
HoverHandler {
|
|
onHoveredChanged: {
|
|
if (hovered) _autoClose.stop();
|
|
else _autoClose.restart();
|
|
}
|
|
}
|
|
|
|
// Left ear
|
|
Item {
|
|
anchors.right: _dropdownRect.left
|
|
anchors.top: parent.top
|
|
width: 8
|
|
height: Math.min(8, _dropdownRect.height)
|
|
clip: true
|
|
visible: _dropdownRect.height > 0
|
|
Canvas {
|
|
anchors.top: parent.top
|
|
width: 8; height: 8
|
|
onPaint: {
|
|
var ctx = getContext("2d");
|
|
ctx.clearRect(0, 0, 8, 8);
|
|
ctx.fillStyle = Theme.barBg;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
|
|
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
|
|
ctx.closePath(); ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right ear (for centered dropdowns)
|
|
Item {
|
|
anchors.left: _dropdownRect.right
|
|
anchors.top: parent.top
|
|
width: 8
|
|
height: Math.min(8, _dropdownRect.height)
|
|
clip: true
|
|
visible: _dropdownRect.height > 0 && !dropdown.alignRight
|
|
Canvas {
|
|
anchors.top: parent.top
|
|
width: 8; height: 8
|
|
onPaint: {
|
|
var ctx = getContext("2d");
|
|
ctx.clearRect(0, 0, 8, 8);
|
|
ctx.fillStyle = Theme.barBg;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
|
|
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
|
|
ctx.closePath(); ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: _dropdownRect
|
|
anchors.right: dropdown.alignRight ? parent.right : undefined
|
|
anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter
|
|
anchors.top: parent.top
|
|
width: dropdown.fullWidth
|
|
height: dropdown.open ? dropdown.fullHeight : 0
|
|
color: Theme.barBg
|
|
radius: 8
|
|
topLeftRadius: 0
|
|
topRightRadius: 0
|
|
bottomRightRadius: dropdown.alignRight ? 0 : 8
|
|
clip: true
|
|
|
|
Behavior on height {
|
|
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
|
|
}
|
|
|
|
Item {
|
|
id: dropdownContent
|
|
anchors.fill: parent
|
|
}
|
|
}
|
|
|
|
// Bottom-right concave ear — connects dropdown bottom to right screen edge
|
|
Item {
|
|
visible: dropdown.alignRight && _dropdownRect.height > 0
|
|
anchors.right: _dropdownRect.right
|
|
anchors.top: _dropdownRect.bottom
|
|
width: 8
|
|
height: Math.min(8, _dropdownRect.height)
|
|
clip: true
|
|
Canvas {
|
|
width: 8; height: 8
|
|
onPaint: {
|
|
var ctx = getContext("2d");
|
|
ctx.clearRect(0, 0, 8, 8);
|
|
ctx.fillStyle = Theme.barBg;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(8, 0);
|
|
ctx.lineTo(8, 8);
|
|
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Context menu
|
|
BarDropdown {
|
|
id: contextMenu
|
|
alignRight: true
|
|
property var trayItem: null
|
|
fullWidth: menuItems.width + 16
|
|
fullHeight: menuItems.height + 12
|
|
|
|
onVisibleChanged: {
|
|
if (!visible) menuOpener.menu = null;
|
|
}
|
|
|
|
QsMenuOpener {
|
|
id: menuOpener
|
|
}
|
|
|
|
Column {
|
|
id: menuItems
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 6
|
|
width: 200
|
|
|
|
Repeater {
|
|
model: menuOpener.children
|
|
|
|
Rectangle {
|
|
required property var modelData
|
|
width: 200
|
|
height: modelData.isSeparator ? 9 : 28
|
|
color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled
|
|
? Theme.base02 : "transparent"
|
|
radius: modelData.isSeparator ? 0 : 4
|
|
|
|
Rectangle {
|
|
visible: modelData.isSeparator
|
|
anchors.centerIn: parent
|
|
width: parent.width - 20
|
|
height: 1
|
|
color: Theme.base03
|
|
}
|
|
|
|
RowLayout {
|
|
visible: !modelData.isSeparator
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
spacing: 8
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: modelData.text ?? ""
|
|
color: modelData.enabled ? Theme.base05 : Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
visible: modelData.buttonType !== QsMenuButtonType.None
|
|
text: modelData.checkState === Qt.Checked ? "\u2713" : ""
|
|
color: Theme.base0D
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 12
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: itemMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
enabled: !modelData.isSeparator && modelData.enabled
|
|
onClicked: {
|
|
modelData.triggered();
|
|
bar.closeAllDropdowns();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Volume dropdown
|
|
BarDropdown {
|
|
id: volDropdown
|
|
alignRight: true
|
|
fullWidth: volDropdownCol.width + 24
|
|
fullHeight: volDropdownCol.height + 16
|
|
autoCloseMs: 3000
|
|
|
|
Column {
|
|
id: volDropdownCol
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 8
|
|
width: 260
|
|
spacing: 8
|
|
|
|
// Master volume
|
|
Text {
|
|
text: "\u{f057e} Master"
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
font.weight: Font.Medium
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
id: masterSliderBg
|
|
width: parent.width - masterVolLabel.width - 8
|
|
height: 20
|
|
radius: 4
|
|
color: Theme.base01
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
Rectangle {
|
|
width: volWidget.sink && volWidget.sink.audio
|
|
? Math.min(1, volWidget.sink.audio.volume) * parent.width : 0
|
|
height: parent.height
|
|
radius: 4
|
|
color: volWidget.muted ? Theme.base03 : Theme.base0D
|
|
Behavior on width { NumberAnimation { duration: 80 } }
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onPressed: (mouse) => setVolume(mouse)
|
|
onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); }
|
|
function setVolume(mouse) {
|
|
if (!volWidget.sink || !volWidget.sink.audio) return;
|
|
let v = Math.max(0, Math.min(1, mouse.x / width));
|
|
volWidget.sink.audio.volume = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: masterVolLabel
|
|
width: 36
|
|
text: volWidget.vol + "%"
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
horizontalAlignment: Text.AlignRight
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
// Mute button
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 28
|
|
color: masterMuteMa.containsMouse ? Theme.base02 : "transparent"
|
|
radius: 4
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute"
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 12
|
|
}
|
|
MouseArea {
|
|
id: masterMuteMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (volWidget.sink && volWidget.sink.audio)
|
|
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Separator
|
|
Rectangle {
|
|
width: parent.width - 20
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
height: 1
|
|
color: Theme.base02
|
|
visible: appStreamsCol.childrenRect.height > 0
|
|
}
|
|
|
|
// App streams header
|
|
Text {
|
|
visible: appStreamsCol.childrenRect.height > 0
|
|
text: "\u{f0641} Applications"
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
font.weight: Font.Medium
|
|
}
|
|
|
|
// Per-app streams
|
|
Column {
|
|
id: appStreamsCol
|
|
width: parent.width
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
id: appStreamsRepeater
|
|
model: Pipewire.nodes
|
|
|
|
Column {
|
|
required property var modelData
|
|
width: parent.width
|
|
spacing: 2
|
|
visible: modelData.isStream && modelData.audio !== null
|
|
|
|
PwObjectTracker {
|
|
objects: [modelData]
|
|
}
|
|
|
|
Text {
|
|
text: modelData.properties["application.name"] || modelData.name || "Unknown"
|
|
color: Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
elide: Text.ElideRight
|
|
width: parent.width
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
width: parent.width - appVolLabel.width - 8
|
|
height: 16
|
|
radius: 3
|
|
color: Theme.base01
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
Rectangle {
|
|
width: modelData.audio
|
|
? Math.min(1, modelData.audio.volume) * parent.width : 0
|
|
height: parent.height
|
|
radius: 3
|
|
color: modelData.audio && modelData.audio.muted
|
|
? Theme.base03 : Theme.base0C
|
|
Behavior on width { NumberAnimation { duration: 80 } }
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onPressed: (mouse) => setVol(mouse)
|
|
onPositionChanged: (mouse) => { if (pressed) setVol(mouse); }
|
|
function setVol(mouse) {
|
|
if (!modelData.audio) return;
|
|
let v = Math.max(0, Math.min(1, mouse.x / width));
|
|
modelData.audio.volume = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: appVolLabel
|
|
width: 36
|
|
text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%"
|
|
color: Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 10
|
|
horizontalAlignment: Text.AlignRight
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Network dropdown
|
|
BarDropdown {
|
|
id: netDropdown
|
|
alignRight: true
|
|
fullWidth: netDropdownCol.width + 24
|
|
fullHeight: netDropdownCol.height + 16
|
|
|
|
Column {
|
|
id: netDropdownCol
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 8
|
|
width: 220
|
|
spacing: 4
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: netWidget.netState === "connected"
|
|
? "\u{f05a9} " + netWidget.netConn
|
|
: "\u{f05aa} Not connected"
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
font.weight: Font.Medium
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Rectangle {
|
|
visible: netWidget.netState === "connected"
|
|
width: parent.width
|
|
height: 28
|
|
color: disconnectMouse.containsMouse ? Theme.base02 : "transparent"
|
|
radius: 4
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "Disconnect"
|
|
color: Theme.base08
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
MouseArea {
|
|
id: disconnectMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: {
|
|
netDisconnectProc.targetDevice = netWidget.netDevice;
|
|
netDisconnectProc.running = true;
|
|
netWidget.netState = "disconnected";
|
|
netWidget.netConn = "";
|
|
netWidget.netIcon = "\u{f05aa}";
|
|
bar.closeAllDropdowns();
|
|
netRefreshDelay.start();
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: parent.width - 20
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
height: 1
|
|
color: Theme.base03
|
|
}
|
|
|
|
Text {
|
|
text: "Available networks"
|
|
color: Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
topPadding: 2
|
|
}
|
|
|
|
Repeater {
|
|
model: netWidget.wifiNetworks
|
|
|
|
Rectangle {
|
|
required property var modelData
|
|
width: 220
|
|
height: 32
|
|
color: netItemMouse.containsMouse ? Theme.base02 : "transparent"
|
|
radius: 4
|
|
|
|
Row {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 8
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 8
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: {
|
|
let s = modelData.signal;
|
|
if (s >= 75) return "\u{f05a9}";
|
|
if (s >= 50) return "\u{f05a9}";
|
|
if (s >= 25) return "\u{f05a9}";
|
|
return "\u{f05aa}";
|
|
}
|
|
color: modelData.active ? Theme.base0B : Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Text {
|
|
text: modelData.ssid
|
|
color: modelData.active ? Theme.base0B : Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
width: 140
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Text {
|
|
visible: modelData.security !== "" && modelData.security !== "--"
|
|
text: "\u{f0341}"
|
|
color: Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: netItemMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: {
|
|
if (!modelData.active) {
|
|
wifiConnectProc.targetSsid = modelData.ssid;
|
|
wifiConnectProc.running = true;
|
|
netRefreshDelay.start();
|
|
}
|
|
bar.closeAllDropdowns();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
${lib.optionalString isMacbook ''
|
|
// Battery dropdown
|
|
BarDropdown {
|
|
id: batteryDropdown
|
|
alignRight: true
|
|
fullWidth: batteryDropdownCol.width + 24
|
|
fullHeight: batteryDropdownCol.height + 16
|
|
|
|
Column {
|
|
id: batteryDropdownCol
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 10
|
|
width: 200
|
|
spacing: 8
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: batteryWidget.batteryIcon
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 18
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Column {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
Text {
|
|
text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "")
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
font.weight: Font.Medium
|
|
}
|
|
Text {
|
|
text: batteryWidget.powerDraw.toFixed(1) + " W"
|
|
+ (batteryWidget.timeRemaining !== "" ? " \u2022 " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "")
|
|
color: Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: parent.width - 10
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
height: 1
|
|
color: Theme.base03
|
|
}
|
|
|
|
Text {
|
|
text: "Power Profile"
|
|
color: Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 4
|
|
|
|
Repeater {
|
|
model: [
|
|
{ name: "power-saver", label: "\u{f0425}", tip: "Saver" },
|
|
{ name: "balanced", label: "\u{f0376}", tip: "Balanced" },
|
|
{ name: "performance", label: "\u{f0e0e}", tip: "Performance" }
|
|
]
|
|
|
|
Rectangle {
|
|
required property var modelData
|
|
width: (parent.width - 8) / 3
|
|
height: 36
|
|
radius: 6
|
|
color: batteryWidget.powerProfile === modelData.name
|
|
? Theme.base02 : profMouse.containsMouse
|
|
? Theme.base01 : "transparent"
|
|
border.width: batteryWidget.powerProfile === modelData.name ? 1 : 0
|
|
border.color: Theme.base03
|
|
|
|
Column {
|
|
anchors.centerIn: parent
|
|
spacing: 1
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: modelData.label
|
|
color: batteryWidget.powerProfile === modelData.name
|
|
? Theme.base0D : Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 14
|
|
}
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: modelData.tip
|
|
color: Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: profMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: {
|
|
setProfileProc.target = modelData.name;
|
|
setProfileProc.running = true;
|
|
batteryWidget.powerProfile = modelData.name;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
''}
|
|
|
|
// Calendar popup
|
|
BarDropdown {
|
|
id: calPopup
|
|
dropdownX: bar.width / 2
|
|
fullWidth: calCol.width + 32
|
|
fullHeight: calCol.height + 24
|
|
|
|
Column {
|
|
id: calCol
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 12
|
|
spacing: 8
|
|
opacity: calPopup.open ? 1.0 : 0.0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
|
|
}
|
|
|
|
Rectangle {
|
|
width: 7 * 32 + 16
|
|
height: calTitle.height + 16
|
|
radius: 8
|
|
color: Theme.base01
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
|
|
Text {
|
|
id: calTitle
|
|
anchors.centerIn: parent
|
|
text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy")
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 16
|
|
font.weight: Font.Medium
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: 7 * 32 + 16
|
|
height: calInner.height + 16
|
|
radius: 8
|
|
color: Theme.base01
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
|
|
Column {
|
|
id: calInner
|
|
anchors.centerIn: parent
|
|
spacing: 4
|
|
|
|
Rectangle {
|
|
width: 7 * 32
|
|
height: weekdayRow.height + 8
|
|
radius: 6
|
|
color: Theme.base02
|
|
|
|
Row {
|
|
id: weekdayRow
|
|
anchors.centerIn: parent
|
|
spacing: 0
|
|
Repeater {
|
|
model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
|
Text {
|
|
required property var modelData
|
|
width: 32
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: modelData
|
|
color: Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Grid {
|
|
columns: 7
|
|
spacing: 0
|
|
|
|
Repeater {
|
|
id: calRepeater
|
|
model: 42
|
|
|
|
Rectangle {
|
|
required property int index
|
|
width: 32
|
|
height: 26
|
|
radius: 4
|
|
color: {
|
|
let d = clockText.now;
|
|
let first = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
let startDay = (first.getDay() + 6) % 7;
|
|
let dayNum = index - startDay + 1;
|
|
let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
|
return (dayNum === d.getDate() && dayNum >= 1 && dayNum <= daysInMonth)
|
|
? Theme.base02 : "transparent";
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: {
|
|
let d = clockText.now;
|
|
let first = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
let startDay = (first.getDay() + 6) % 7;
|
|
let dayNum = parent.index - startDay + 1;
|
|
let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
|
return (dayNum >= 1 && dayNum <= daysInMonth) ? dayNum.toString() : "";
|
|
}
|
|
color: {
|
|
let d = clockText.now;
|
|
let first = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
let startDay = (first.getDay() + 6) % 7;
|
|
let dayNum = parent.index - startDay + 1;
|
|
return (dayNum === d.getDate()) ? Theme.base05 : Theme.base04;
|
|
}
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: 7 * 32 + 16
|
|
height: 1
|
|
color: Theme.base02
|
|
}
|
|
|
|
Row {
|
|
width: 7 * 32 + 16
|
|
Text {
|
|
text: "Notifications"
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
font.weight: Font.Medium
|
|
}
|
|
Item { Layout.fillWidth: true; width: 10 }
|
|
Text {
|
|
anchors.right: parent.right
|
|
text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : ""
|
|
color: Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
let notifs = bar.notifServer.trackedNotifications.values;
|
|
for (let i = notifs.length - 1; i >= 0; i--) {
|
|
notifs[i].dismiss();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
spacing: 4
|
|
width: 7 * 32 + 16
|
|
|
|
Text {
|
|
visible: bar.notifServer.trackedNotifications.values.length === 0
|
|
text: "No notifications"
|
|
color: Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
}
|
|
|
|
Repeater {
|
|
model: bar.notifServer.trackedNotifications
|
|
|
|
Rectangle {
|
|
id: notifItem
|
|
required property var modelData
|
|
width: 7 * 32 + 16
|
|
height: notifCol.height + 12
|
|
radius: 6
|
|
color: Theme.base01
|
|
|
|
Column {
|
|
id: notifCol
|
|
anchors.left: parent.left
|
|
anchors.right: dismissBtn.left
|
|
anchors.top: parent.top
|
|
anchors.margins: 6
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: notifItem.modelData.summary || notifItem.modelData.appName
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
font.weight: Font.Medium
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: notifItem.modelData.body || ""
|
|
color: Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 10
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 2
|
|
wrapMode: Text.Wrap
|
|
visible: text !== ""
|
|
}
|
|
|
|
Row {
|
|
spacing: 4
|
|
visible: notifItem.modelData.actions.length > 0
|
|
Repeater {
|
|
model: notifItem.modelData.actions
|
|
Rectangle {
|
|
required property var modelData
|
|
width: actionText.width + 12
|
|
height: actionText.height + 4
|
|
radius: 4
|
|
color: actionMa.containsMouse ? Theme.base02 : Theme.base01
|
|
border.width: 1
|
|
border.color: Theme.base02
|
|
Text {
|
|
id: actionText
|
|
anchors.centerIn: parent
|
|
text: modelData.text
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 10
|
|
}
|
|
MouseArea {
|
|
id: actionMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: modelData.invoke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: dismissBtn
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: 6
|
|
text: "\u{f0156}"
|
|
color: dismissMa.containsMouse ? Theme.base05 : Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 12
|
|
MouseArea {
|
|
id: dismissMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: notifItem.modelData.dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
'';
|
|
};
|
|
|
|
"quickshell/NotificationToast.qml" = {
|
|
onChange = qsRestart;
|
|
text = ''
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
PanelWindow {
|
|
id: notifToast
|
|
required property var shellRoot
|
|
screen: Quickshell.screens[0]
|
|
property var currentNotif: null
|
|
property bool open: false
|
|
|
|
readonly property var mutedApps: ["discord", "Discord", "Spotify", "spotify", "vlc", "mpv"]
|
|
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "quickshell-toast"
|
|
exclusionMode: ExclusionMode.Ignore
|
|
|
|
Process {
|
|
id: notifSoundProc
|
|
command: [Commands.notifSound, "-i", "message"]
|
|
}
|
|
|
|
Connections {
|
|
target: shellRoot
|
|
function onNotificationReceived() {
|
|
notifToast.show(shellRoot.latestNotification);
|
|
}
|
|
}
|
|
|
|
function show(notification) {
|
|
currentNotif = notification;
|
|
visible = true;
|
|
open = true;
|
|
_toastTimer.restart();
|
|
if (!mutedApps.includes(notification.appName)) {
|
|
notifSoundProc.running = true;
|
|
}
|
|
}
|
|
|
|
function dismiss() {
|
|
open = false;
|
|
_toastCloseDelay.start();
|
|
}
|
|
|
|
anchors.top: true
|
|
margins.top: 30
|
|
visible: false
|
|
implicitWidth: 320 + 16
|
|
implicitHeight: _toastRect.height + 4
|
|
color: "transparent"
|
|
|
|
Timer {
|
|
id: _toastTimer
|
|
interval: 5000
|
|
onTriggered: notifToast.dismiss()
|
|
}
|
|
|
|
Timer {
|
|
id: _toastCloseDelay
|
|
interval: 230
|
|
onTriggered: { notifToast.visible = false; notifToast.open = false; }
|
|
}
|
|
|
|
HoverHandler {
|
|
onHoveredChanged: {
|
|
if (hovered) _toastTimer.stop();
|
|
else _toastTimer.restart();
|
|
}
|
|
}
|
|
|
|
// Left inverse corner ear
|
|
Item {
|
|
anchors.right: _toastRect.left
|
|
anchors.top: parent.top
|
|
width: 8
|
|
height: Math.min(8, _toastRect.height)
|
|
clip: true
|
|
visible: _toastRect.height > 0
|
|
Canvas {
|
|
anchors.top: parent.top
|
|
width: 8; height: 8
|
|
onPaint: {
|
|
var ctx = getContext("2d");
|
|
ctx.clearRect(0, 0, 8, 8);
|
|
ctx.fillStyle = Theme.barBg;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
|
|
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
|
|
ctx.closePath(); ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right inverse corner ear
|
|
Item {
|
|
anchors.left: _toastRect.right
|
|
anchors.top: parent.top
|
|
width: 8
|
|
height: Math.min(8, _toastRect.height)
|
|
clip: true
|
|
visible: _toastRect.height > 0
|
|
Canvas {
|
|
anchors.top: parent.top
|
|
width: 8; height: 8
|
|
onPaint: {
|
|
var ctx = getContext("2d");
|
|
ctx.clearRect(0, 0, 8, 8);
|
|
ctx.fillStyle = Theme.barBg;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
|
|
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
|
|
ctx.closePath(); ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: _toastRect
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.top: parent.top
|
|
width: 320
|
|
height: notifToast.open ? toastCol.height + 16 : 0
|
|
color: Theme.barBg
|
|
radius: 8
|
|
topLeftRadius: 0
|
|
topRightRadius: 0
|
|
clip: true
|
|
|
|
Behavior on height {
|
|
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
|
|
}
|
|
|
|
Column {
|
|
id: toastCol
|
|
anchors.left: parent.left
|
|
anchors.right: toastDismiss.left
|
|
anchors.top: parent.top
|
|
anchors.margins: 8
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: notifToast.currentNotif ? (notifToast.currentNotif.summary || notifToast.currentNotif.appName) : ""
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 12
|
|
font.weight: Font.Medium
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: notifToast.currentNotif ? (notifToast.currentNotif.body || "") : ""
|
|
color: Theme.base04
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 11
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 3
|
|
wrapMode: Text.Wrap
|
|
visible: text !== ""
|
|
}
|
|
|
|
Row {
|
|
spacing: 4
|
|
visible: notifToast.currentNotif && notifToast.currentNotif.actions.length > 0
|
|
Repeater {
|
|
model: notifToast.currentNotif ? notifToast.currentNotif.actions : []
|
|
Rectangle {
|
|
required property var modelData
|
|
width: toastActionText.width + 12
|
|
height: toastActionText.height + 6
|
|
radius: 4
|
|
color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01
|
|
border.width: 1
|
|
border.color: Theme.base02
|
|
Text {
|
|
id: toastActionText
|
|
anchors.centerIn: parent
|
|
text: modelData.text
|
|
color: Theme.base05
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 10
|
|
}
|
|
MouseArea {
|
|
id: toastActionMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: { modelData.invoke(); notifToast.dismiss(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: toastDismiss
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: 8
|
|
text: "\u{f0156}"
|
|
color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03
|
|
font.family: "FiraMono Nerd Font"
|
|
font.pixelSize: 13
|
|
MouseArea {
|
|
id: toastDismissMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: { notifToast.currentNotif.dismiss(); notifToast.dismiss(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
'';
|
|
};
|
|
};
|
|
|
|
};
|
|
};
|
|
}
|