This project is a standalone Quickshell config while the widget is still being shaped. Keep it runnable next to an existing end-4 dots setup.
Run from the repository root:
quickshell --path quickshellFor a short smoke test that exits automatically:
timeout 5 quickshell --path quickshell --verboseExpected successful load:
INFO: Configuration Loaded
Quickshell reloads when QML files change, so keep this process open while editing.
Idle behavior:
- the default shape is a small pure-black bump at the top center
- it reserves only a small constant top zone through layer-shell
- hover expansion can overlap windows; this avoids Hyprland pushing windows down on every hover
- the visible strip can be very thin, but the idle hitbox remains as tall as the reserved zone
- top corners are sharp where the shape touches the screen edge
- bottom corners use a capped adaptive radius; avoid
height / 2on large media layouts because it makes the island and volume trace too round - collapsed idle corners are slightly softer than before, but the handle must still read as a top-attached glacier, not a pill
- hover the bump to expand it temporarily
- hover shows a tiny text-style bump/strip switch inside the idle island content; the same styles are also available through IPC
- hover shows battery charge in the menu when UPower exposes a laptop display battery
- if a MPRIS player has active media, hovering the bump shows a compact media player instead of the generic idle peek
- click the bump to pin or unpin the expanded idle state
- click while a non-media event state is visible to collapse back to idle
Run these in another terminal from the repository root:
quickshell ipc --path quickshell showThe output should include target dynamicGlacier.
Manual state triggers:
quickshell ipc --path quickshell call dynamicGlacier demo
quickshell ipc --path quickshell call dynamicGlacier notify "Build finished" "Dynamic Glacier is alive" "Hello"
quickshell ipc --path quickshell call dynamicGlacier media "Night Drive" "Glacier FM" true ""
quickshell ipc --path quickshell call dynamicGlacier volume 72 false
quickshell ipc --path quickshell call dynamicGlacier handle bump
quickshell ipc --path quickshell call dynamicGlacier handle strip
quickshell ipc --path quickshell call dynamicGlacier toggleHandle
quickshell ipc --path quickshell call dynamicGlacier privacy true false
quickshell ipc --path quickshell call dynamicGlacier privacy false true
quickshell ipc --path quickshell call dynamicGlacier privacyLive
quickshell ipc --path quickshell call dynamicGlacier live true
quickshell ipc --path quickshell call dynamicGlacier live false
quickshell ipc --path quickshell call dynamicGlacier idleprivacy true false forces the green microphone dot. privacy false true forces the green camera dot. privacyLive clears the override and returns to real PipeWire/fallback detection.
Looping demo:
quickshell ipc --path quickshell call dynamicGlacier demoLoopRun demoLoop again to stop the loop.
List this config's instances:
quickshell list --path quickshellList every Quickshell instance, including end-4's shell:
quickshell list --allFollow logs for this config:
quickshell log --path quickshell --followStop only this config:
quickshell kill --path quickshellDo not kill all Quickshell instances unless you intentionally want to stop the main end-4 shell too.
quickshell/shell.qml: root entrypoint. Keep this small.quickshell/modules/dynamicGlacier/DynamicGlacier.qml: state machine, timers, window placement, IPC API.quickshell/modules/dynamicGlacier/IslandSurface.qml: outer shape, glow, background, animation of shell geometry.quickshell/modules/dynamicGlacier/IslandContent.qml: per-mode content layout for idle, notification, and media.quickshell/modules/dynamicGlacier/IslandSurface.qml: also owns the subtle open U-shaped volume trace.docs/architecture.md: bigger design decisions and integration notes.
If a change affects runtime behavior, test it with timeout 5 quickshell --path quickshell --verbose and at least one IPC command.
Dynamic Glacier currently listens to:
- MPRIS media players through
Quickshell.Services.Mpris - PipeWire default sink volume through
Quickshell.Services.Pipewire - PipeWire link groups for microphone/video privacy activity through
Quickshell.Services.Pipewire - shell fallbacks for privacy activity:
pactl list source-outputs shortfor microphone capture andfuser /dev/video*for direct V4L2 camera users - UPower display battery through
Quickshell.Services.UPower
Volume deliberately does not open a full island state because end-4 already has its own volume OSD. It only paints a light gray open U-shaped trace just inside the current island perimeter for about a second; the top edge must stay unconnected.
Privacy indicator behavior:
- a small green dot appears to the right of the island when mic or camera privacy activity is detected
- the dot is hidden immediately during hover; it must not animate or slide back into place after hover closes
- the dot is informational only and should not reserve layout space or move Hyprland windows
- if real camera/mic activity does not show the dot, first test the visual path with IPC
privacy; then inspect PipeWire links and the shell fallbacks because some direct V4L2 camera users bypass PipeWire
Media hover behavior:
- hovering the idle handle shows the media layout when a MPRIS player has an active track
- live MPRIS track changes must not call
showMedia()by themselves; they only update cached track fields for the next hover - the media layout should stay compact; fix clipping with internal padding and smaller controls/artwork, not by making the island tall
- album art is read from MPRIS
trackArtUrl; if it is missing or not loaded, the widget falls back to the minimal equalizer mark - track progress uses the active MPRIS player's
positionandlength; the formatter accepts either seconds or MPRIS microseconds - clicking or dragging the media progress bar requests an absolute seek on the active MPRIS player when
canSeekandpositionSupportedare true - previous, play/pause, and next buttons sit under the timeline and call the active MPRIS player directly through Quickshell
- while the media layout is visible, the hover hitbox does not accept clicks so the player buttons can receive them
- the
bump / striphandle switch is also visible in the media layout, not only in the generic idle hover content
Hover menu behavior:
- keep the bump/strip switch inside
IslandContent.qml, not as a separate overlay sibling inDynamicGlacier.qml - use
HandleStyleSwitch.qmlfor both idle and media layouts so hover/click behavior stays consistent - the switch should be text-like and minimal so it does not collide with the idle label
- when
interactionOpenis true, the top-level hover hitbox must useQt.NoButton; otherwise it can steal clicks from the internal switch - privacy dots intentionally disappear during hover so they do not fight with expanded island interaction
Check available live sources:
playerctl -l
playerctl metadata --format '{{playerName}}|{{status}}|{{position}}|{{mpris:length}}|{{artist}}|{{title}}|{{mpris:artUrl}}'
wpctl get-volume @DEFAULT_AUDIO_SINK@
upower -e
upower -i /org/freedesktop/UPower/devices/DisplayDeviceInspect active PipeWire source/link state:
pw-dump | jq -r '.[] | select(.type == "PipeWire:Interface:Node") | [.id, .info.props["media.class"], .info.props["node.name"], .info.props["node.description"], .info.props["application.name"]] | @tsv' | rg 'Audio/Source|Video/Source|Stream/Input/Audio|camera|microphone'
pw-dump --monitor | rg 'Audio/Source|Video/Source|Stream/Input/Audio|camera|microphone'
pactl list source-outputs short
fuser /dev/video*Manual privacy visual tests:
quickshell ipc --path quickshell call dynamicGlacier privacy true false
quickshell ipc --path quickshell call dynamicGlacier privacy false true
quickshell ipc --path quickshell call dynamicGlacier privacyLiveLive microphone trigger test:
timeout 5 pw-record --media-category=Capture --media-role=Communication /tmp/dynamic-glacier-mic-test.wavLive camera trigger test through PipeWire:
pw-dump | jq -r '.[] | select(.type == "PipeWire:Interface:Node") | select((.info.props["media.class"] // "") | test("Video/Source")) | [.id, .info.props["node.name"], .info.props["node.description"]] | @tsv'
timeout 5 gst-launch-1.0 pipewiresrc target-object=<node-name-or-id> ! videoconvert ! fakesinkBrowser/portal camera usage should normally be visible through PipeWire link groups. Direct /dev/video* users are detected by the fuser /dev/video* fallback when the device node is visible to the session.
Safe volume event test:
wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%+
wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-Optional media event test:
playerctl play-pause
playerctl play-pause
playerctl position 10+
playerctl position 10-The media test intentionally toggles playback and seeks the active player; do not run it if you do not want to touch the active player. Track changes from live MPRIS should not expand the island automatically; hover the idle handle to inspect the updated track.
- Do not create repo-local
AGENTS.md; project memory belongs in/home/mavxa/zed/agents/. - Keep the prototype standalone until integration points are clear.
- Do not add
NotificationServerby default yet. It can conflict with the existing end-4 notification daemon. - Do not depend on JS/HTML/CSS, Electron, webviews, AGS, or EWW for the main UI.
- Prefer a small runnable QML slice over speculative architecture.
- Keep state changes centralized in
DynamicGlacier.qml; services should request mode transitions, not resize the island directly. - Keep live service listeners non-owning. MPRIS, PipeWire, and UPower are safe; notifications need bridge/integration work.
- Keep the visual identity minimal, OLED-friendly, and distinct from Apple's Dynamic Island.
- Default visuals should be pure black first; add visible decoration only when it materially improves interaction clarity.
- Keep the island in normal
WlrLayer.TopwithExclusionMode.Normal, but keepexclusiveZoneconstant and small. - Do not bind
exclusiveZoneto expanded island height; that makes Hyprland push windows down during hover. - Keep the attached-top silhouette: sharp top corners, capped rounded bottom corners.
No running instances for ".../quickshell/shell.qml" means the widget process is not running or already exited. Start it again with quickshell --path quickshell.
IpcHandler is not a type usually means DynamicGlacier.qml is missing import Quickshell.Io.
Module path contains invalid characters means a QML import directory probably contains a hyphen. Keep the module directory named dynamicGlacier, not dynamic-glacier.
If the island does not appear but logs say Configuration Loaded, check whether it is hidden behind another layer or on the focused monitor. Current placement follows Hyprland.focusedMonitor and falls back to the first Quickshell screen.
If tiled windows move too much on hover, check DynamicGlacier.qml first. exclusiveZone should be root.reservedZone, not implicitHeight.
If the idle handle is too hard to find, test strip mode:
quickshell ipc --path quickshell call dynamicGlacier handle stripIf a gap appears between the island and the top screen edge, check expandedTopMargin in DynamicGlacier.qml. It should stay 0; the island should grow downward from the top edge.
If text looks clipped or deformed, check IslandSurface.qml z-ordering. Background shape rectangles should stay below IslandContent.
If live events get annoying while editing, disable them temporarily:
quickshell ipc --path quickshell call dynamicGlacier live false- Run with the main end-4 shell active and verify there is no notification daemon conflict.
- Test on each monitor by focusing a window there before starting the widget.
- Test long notification and media strings for clipping and layout jumps.
- Test volume values
0,1,50,100, and muted state; verify only the open U-shaped trace appears, not a second mixer and not a top-connected loop. - Test media hover with an active player and verify the timeline updates and previous/play-pause/next buttons are clickable.
- Keep an eye on animation feel after each visual change; aggressive motion will get annoying quickly.