Skip to content

Add clamshell plugin#821

Draft
Sovego wants to merge 5 commits into
noctalia-dev:mainfrom
Sovego:add-clamshell-plugin
Draft

Add clamshell plugin#821
Sovego wants to merge 5 commits into
noctalia-dev:mainfrom
Sovego:add-clamshell-plugin

Conversation

@Sovego
Copy link
Copy Markdown

@Sovego Sovego commented May 4, 2026

Summary

Adds clamshell, a Noctalia plugin for automatic laptop clamshell mode on niri.

When an external display is connected and the plugin is enabled, it starts a single systemd-inhibit process to block handle-lid-switch. This lets niri
turn off the internal display on lid close without the system suspending. When no external display is present, or the user disables the plugin, the
inhibitor is stopped and normal lid behavior returns.

Features

  • Monitors niri output changes through niri msg --json event-stream
  • Reactively refreshes niri msg --json outputs without polling
  • Detects external displays via configurable internal connector regex
  • Provides Control Center and bar widgets
  • Provides settings for enable state, bar visibility, notifications, internal connector regex, and inhibitor identifier
  • Exposes IPC target plugin:clamshell
  • Sends Noctalia toast notifications on inhibitor state changes
  • Does not modify /etc/systemd/logind.conf

IPC

qs -c noctalia-shell ipc call plugin:clamshell status
qs -c noctalia-shell ipc call plugin:clamshell toggle
qs -c noctalia-shell ipc call plugin:clamshell enable
qs -c noctalia-shell ipc call plugin:clamshell disable
qs -c noctalia-shell ipc call plugin:clamshell refresh

Testing

Tested locally on:

  • niri
  • CachyOS
  • Noctalia Shell / noctalia-qs
  • systemd with systemd-inhibit

Verified:

  • Plugin loads from local source
  • Settings translations render correctly
  • Control Center and bar widgets load
  • IPC status, toggle, enable, disable, and refresh work
  • External monitor detection works with eDP-1 internal and DP-2 external
  • systemd-inhibit --list shows one noctalia-clamshell inhibitor when active
  • Inhibitor is removed when plugin is disabled
  • Noctalia toast notifications display with icon
  • Static contract test passes:
  • node tests/static-contract.test.mjs

Notes

The plugin intentionally uses systemd-inhibit --mode=block and does not change logind configuration. Users should keep normal lid behavior in logind.conf, typically HandleLidSwitch=suspend.

@github-actions

This comment was marked as resolved.

Copy link
Copy Markdown
Collaborator

@spiros132 spiros132 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some feedback about the PR! :D

Comment thread clamshell/manifest.json
@@ -0,0 +1,33 @@
{
"id": "clamshell",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is specifically for niri, I would suggest renaming the plugin to niri-clamshell to make it more clear.

Comment thread clamshell/manifest.json
"ipc": {
"target": "plugin:clamshell",
"functions": ["enable", "disable", "toggle", "status", "refresh"]
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ipc field in the manifest doesn't do anything.

Comment thread clamshell/README.md
```

Register and enable the plugin in Noctalia, then add the bar widget or control
center widget from Noctalia settings.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installation part isn't correct since the user can just press install on the plugin.

Comment thread clamshell/Settings.qml

property bool editEnabled: cfg.enabled !== undefined
? cfg.enabled
: (defaults.enabled !== undefined ? defaults.enabled : true)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer to use the following syntax instead of this:

property bool foo: cfg?.foo ?? defaults?.foo ?? true

Comment thread clamshell/Settings.qml
: (defaults.alwaysShowBarWidget !== undefined ? defaults.alwaysShowBarWidget : false)
property bool editNotify: cfg.notify !== undefined
? cfg.notify
: (defaults.notify !== undefined ? defaults.notify : true)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer to use that syntax on all of these as well.

Comment thread clamshell/Main.qml
?? "^(eDP|LVDS|DSI)"
readonly property bool notifyEnabled: cfg.notify !== undefined
? cfg.notify
: (defaults.notify !== undefined ? defaults.notify : true)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As before, use the other syntax for this property.

Comment thread clamshell/Main.qml
// because widgets and IPC mutate this property directly.
property bool enabled: (cfg.enabled !== undefined)
? cfg.enabled
: (defaults.enabled !== undefined ? defaults.enabled : true)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As before the same thing here. Also I would suggest making this property readonly and when you need to change it, change it through the pluginApi.pluginSettings.enabled which will update the property automatically.

Comment thread clamshell/Main.qml
function applySettings() {
root.enabled = (cfg.enabled !== undefined)
? cfg.enabled
: (defaults.enabled !== undefined ? defaults.enabled : true);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As before this isn't needed if you use the reactive properties of qml instead of manually setting enabled every time.

Comment thread clamshell/Main.qml
Logger.w(root.logTag, "niri msg outputs exited with code", exitCode);
}
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this Process specifically? Quickshell already provides a way to know which monitors exists with the Quickshell.screens.

Comment thread clamshell/Main.qml
// (niri generates WorkspacesChanged etc. when monitor topology changes).
// The debounce timer coalesces rapid bursts into a single fetch.
outputsDebounce.restart();
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing as well, I believe that noctalia has a built-in way to get the outputs in the NiriService. That way you don't need to have more boilerplate code that does the exact same thing as noctalia.

@spiros132 spiros132 marked this pull request as draft May 14, 2026 12:18
@Sovego
Copy link
Copy Markdown
Author

Sovego commented May 14, 2026

Thanks for feedback. Its my first PR to such big project) So I fix this issues as soon as possible

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants