nixos/settings/hyprland.nix
rope 3b7cc98fc9 quickshell: fix clock click reopening after grabFocus dismiss
Clicking clock while popup open triggered grabFocus dismiss
then immediately reopened. Add justDismissed guard so the
click handler ignores the click that caused the dismiss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 15:23:55 +01:00

1038 lines
46 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
mako
grim
slurp
wl-clipboard
cliphist
brightnessctl
swayosd
playerctl
hyprpaper
hyprlock
hypridle
hyprshot
networkmanagerapplet
pavucontrol
polkit_gnome
quickshell
qt6.qt5compat
];
# 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")
hl.exec_cmd("qs")
hl.exec_cmd("mako")
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;
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";
xdg.configFile."quickshell/shell.qml" = {
text = ''
//@ pragma UseQApplication
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
ShellRoot {
Variants {
model: Quickshell.screens
PanelWindow {
id: bar
required property var modelData
screen: modelData
anchors {
top: true
left: true
right: true
}
implicitHeight: 30
color: "#D1${c.base00}"
// 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 ? "#${c.base05}" : "#${c.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: "#${c.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: "#${c.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
onClicked: {
if (calPopup.justDismissed) return;
if (calPopup.visible) {
calPopup.open = false;
calCloseTimer.start();
} else {
calPopup.visible = true;
}
}
}
Timer {
id: calCloseTimer
interval: 230
onTriggered: calPopup.visible = false
}
}
// Right network, battery, tray
Row {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 10
// Network status
Item {
id: netWidget
width: 16
height: 30
property string netState: "disconnected"
property string netConn: ""
property string netType: ""
property string netIcon: "\u{f0b0}"
Timer {
interval: 5000
running: true
repeat: true
triggeredOnStart: true
onTriggered: netProc.running = true
}
Process {
id: netProc
command: ["${pkgs.networkmanager}/bin/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 (state === "connected") {
netWidget.netState = "connected";
netWidget.netConn = conn;
netWidget.netType = type;
netWidget.netIcon = type === "wifi" ? "\u{f05a9}" : "\u{f0200}";
} else if (netWidget.netState !== "connected") {
netWidget.netState = "disconnected";
netWidget.netConn = "";
netWidget.netType = type;
netWidget.netIcon = type === "wifi" ? "\u{f05aa}" : "\u{f0201}";
}
}
}
}
Text {
anchors.centerIn: parent
text: netWidget.netIcon
color: "#${c.base05}"
font.family: "FiraMono Nerd Font"
font.pixelSize: 14
}
Process {
id: nmEditorProc
command: ["${pkgs.networkmanagerapplet}/bin/nm-connection-editor"]
}
MouseArea {
anchors.fill: parent
onClicked: nmEditorProc.running = true
}
}
${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}"
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: 10000
running: true
repeat: true
triggeredOnStart: true
onTriggered: batteryProc.running = true
}
Process {
id: batteryProc
command: ["sh", "-c", "cat /sys/class/power_supply/BAT0/capacity; cat /sys/class/power_supply/BAT0/status"]
stdout: SplitParser {
onRead: data => {
let trimmed = data.trim();
let num = parseInt(trimmed);
if (!isNaN(num) && num >= 0 && num <= 100) {
batteryWidget.batteryLevel = num;
} else if (trimmed.length > 0) {
batteryWidget.charging = (trimmed === "Charging");
}
batteryWidget.updateIcon();
}
}
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
id: batteryText
text: batteryWidget.batteryLevel + "%"
color: batteryWidget.batteryLevel <= 15 ? "#${c.base08}"
: batteryWidget.batteryLevel <= 30 ? "#${c.base0A}"
: "#${c.base05}"
font.family: "FiraMono Nerd Font"
font.pixelSize: 13
}
Text {
id: batteryIconText
text: batteryWidget.batteryIcon
color: batteryWidget.batteryLevel <= 15 ? "#${c.base08}"
: batteryWidget.batteryLevel <= 30 ? "#${c.base0A}"
: "#${c.base05}"
font.family: "FiraMono Nerd Font"
font.pixelSize: 14
}
}
}
''}
// Tray icons
Row {
id: trayArea
spacing: 8
anchors.verticalCenter: parent.verticalCenter
Repeater {
model: SystemTray.items
Item {
required property var modelData
width: 16
height: 16
Image {
id: trayIcon
anchors.fill: parent
source: modelData.icon
sourceSize.width: 16
sourceSize.height: 16
smooth: true
mipmap: true
visible: false
}
ColorOverlay {
anchors.fill: trayIcon
source: trayIcon
color: "#${c.base05}"
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (event) => {
if (event.button === Qt.RightButton && modelData.hasMenu) {
contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu;
contextMenu.visible = true;
} else {
modelData.activate();
}
}
}
}
}
}
}
// Custom-rendered context menu
PopupWindow {
id: contextMenu
property var trayItem: null
anchor.item: trayArea
anchor.edges: Edges.Bottom | Edges.Right
anchor.gravity: Edges.Bottom | Edges.Left
anchor.adjustment: PopupAdjustment.Slide
grabFocus: true
visible: false
color: "transparent"
implicitWidth: menuColumn.width + 2
implicitHeight: menuColumn.height + 2
QsMenuOpener {
id: menuOpener
}
Rectangle {
id: menuColumn
width: menuItems.width + 16
height: menuItems.height + 12
color: "#${c.base00}"
border.color: "#${c.base03}"
border.width: 1
radius: 8
Column {
id: menuItems
anchors.centerIn: parent
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
? "#${c.base02}" : "transparent"
radius: modelData.isSeparator ? 0 : 4
// Separator line
Rectangle {
visible: modelData.isSeparator
anchors.centerIn: parent
width: parent.width - 20
height: 1
color: "#${c.base03}"
}
// Menu item content
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 ? "#${c.base05}" : "#${c.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: "#${c.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();
contextMenu.visible = false;
}
}
}
}
}
}
onVisibleChanged: {
if (!visible) {
menuOpener.menu = null;
}
}
}
// Calendar popup
PopupWindow {
id: calPopup
anchor.window: bar
anchor.rect.x: bar.width / 2 - (fullWidth + 16) / 2
anchor.rect.y: bar.height
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Bottom | Edges.Right
anchor.adjustment: PopupAdjustment.Slide
grabFocus: true
visible: false
color: "transparent"
property bool open: false
property bool justDismissed: false
property real fullWidth: calCol.width + 32
property real fullHeight: calCol.height + 24
implicitWidth: fullWidth + 16
implicitHeight: fullHeight + 4
onVisibleChanged: {
if (visible) {
open = true;
} else {
open = false;
justDismissed = true;
dismissGuardTimer.start();
}
}
Timer {
id: dismissGuardTimer
interval: 100
onTriggered: calPopup.justDismissed = false
}
// Concave corner left
Item {
anchors.right: calContent.left
anchors.top: parent.top
width: 8
height: Math.min(8, calContent.height)
clip: true
visible: calContent.height > 0
Canvas {
anchors.top: parent.top
width: 8
height: 8
onPaint: {
var ctx = getContext("2d");
var r = 8;
ctx.clearRect(0, 0, r, r);
ctx.fillStyle = "#D1${c.base00}";
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(r, 0);
ctx.lineTo(r, r);
ctx.arc(0, r, r, 0, -Math.PI / 2, true);
ctx.closePath();
ctx.fill();
}
}
}
// Concave corner right
Item {
anchors.left: calContent.right
anchors.top: parent.top
width: 8
height: Math.min(8, calContent.height)
clip: true
visible: calContent.height > 0
Canvas {
anchors.top: parent.top
width: 8
height: 8
onPaint: {
var ctx = getContext("2d");
var r = 8;
ctx.clearRect(0, 0, r, r);
ctx.fillStyle = "#D1${c.base00}";
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(r, 0);
ctx.arc(r, r, r, -Math.PI / 2, Math.PI, true);
ctx.closePath();
ctx.fill();
}
}
}
Rectangle {
id: calContent
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 0
width: calPopup.open ? calPopup.fullWidth : calPopup.fullWidth
height: calPopup.open ? calPopup.fullHeight : 0
color: "#D1${c.base00}"
border.width: 0
radius: 8
topLeftRadius: 0
topRightRadius: 0
clip: true
Behavior on height {
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
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 }
}
// Date header
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy")
color: "#${c.base05}"
font.family: "FiraMono Nerd Font"
font.pixelSize: 14
font.weight: Font.Medium
}
// Day headers
Row {
spacing: 0
Repeater {
model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
Text {
required property var modelData
width: 28
horizontalAlignment: Text.AlignHCenter
text: modelData
color: "#${c.base03}"
font.family: "FiraMono Nerd Font"
font.pixelSize: 11
}
}
}
// Calendar grid
Grid {
columns: 7
spacing: 0
Repeater {
id: calRepeater
model: 42
Rectangle {
required property int index
width: 28
height: 24
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)
? "#${c.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()) ? "#${c.base05}" : "#${c.base04}";
}
font.family: "FiraMono Nerd Font"
font.pixelSize: 11
}
}
}
}
}
}
}
}
}
}
'';
};
};
};
}