1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-10 05:03:28 -04:00

Compare commits

...

86 Commits

Author SHA1 Message Date
purian23 f8b32cc298 refactor(framemode): connected surfaces 2026-06-09 13:40:53 -04:00
goatnath 8856d45887 add local to-do planner / tasks to calendar overvie… (#2583)
* feat(quickshell): add local to-do planner / tasks to calendar overview card

* feat(quickshell): add auto-focus and task reordering support in calendar planner

* feat(quickshell): implement smooth drag-and-drop task reordering and inline editing

* fix(quickshell): resolve overlap and jitter in task drag-and-drop

* fix(quickshell): fix boundary swaps and prevent task list scrambling on reload

* fix(quickshell): resolve race, fix qml error, simplify dragging, and remove python dependency

* fix(quickshell): use Log service instead of console.warn in CalendarService

* style: format QML files w/qmlformat-qt6

---------
2026-06-09 00:43:41 -04:00
jbwfu 38af56c6fd fix(clipboard): exit saved filter when pinned entries are empty (#2604) 2026-06-08 23:54:06 -04:00
jbwfu 9111e4809d fix(powermenu): close control center on lock and power actions (#2598) 2026-06-08 23:53:43 -04:00
purian23 d08c7c5e55 refactor(frame): improve connected mode surface recovery
- share modal and launcher ownership handling
- recover missing background and blur layers
2026-06-07 17:47:24 -04:00
purian23 69f3dee25a feat(settings): add compositor section & restructured settings
- add dedicated Compositor pages for comp specifc features
- add Dank Bar Appearance subsection
- improve lazy loading, caching, search routing, & IPC navigation
- standardized responsive Setting categories from global animations
2026-06-07 03:52:00 -04:00
purian23 8155970ba2 fix(fonts): auto-rebuild font cache when configured fonts are missing
- Add Fonts category to dms doctor for manual diagnostics
- Fix a default font setting warning
2026-06-06 19:24:52 -04:00
pathmann d356957dad fix: ignore keyboard shortcuts of disabled powermenu actions (#2580)
* fix: ignore keyboard shortcuts of disabled powermenu actions

* fix typo when checking for lock shortcut

* ignore shortcuts for hidden powermenu actions in grid navigation

* ignore keyboard shortcuts of disabled actions in lock power menu

* ignore keyboard shortcuts of disabled actions in lock power menu (list navigation)
2026-06-06 18:28:38 -04:00
purian23 e7ccb702a3 refactor: update KeybindsModal dynamic sizing 2026-06-05 23:17:14 -04:00
Connor Welsh bf3ce6deb2 fix(osd): size from PanelWindow.screen (#2582) 2026-06-05 23:14:51 -04:00
purian23 f5295fb35d fix(greeter): remove auto-login state resolution from first install configuration
- Update auto-login command to:
`dms greeter sync --autologin`
2026-06-05 23:05:56 -04:00
purian23 6c5836722a fix(authModals): enable overlay layer for for auth popups 2026-06-05 21:27:37 -04:00
Youseffo13 5716249bd9 (Control Center): revamp of 25% pill option (#2568)
* revamp of control center

* update comment of SmallCompoundButton.qml
2026-06-05 19:46:01 -04:00
purian23 4d0aab773b fix(wallpaper): external management toggle & partial monitor DPMS recovery
Fixes #2579, #2581
2026-06-05 19:36:23 -04:00
purian23 e50ac208e3 feat(mangowm): add live config reloads & misc QOL updates
- Hide workspace tags during Mango overview
- Add HJKL focus/move defaults
- Add Mango natural touchpad scrolling &  cursor configs
- Fix Mango startup
2026-06-05 10:53:26 -04:00
bbedward bcb5617194 plugins: add support for composite plugins
- single plugin can register multiple types - e.g. daemon, bar widget,
  desktop widget
2026-06-05 10:33:34 -04:00
bbedward d3c23ba737 settings: add missing tabs to index and tweak search scoring 2026-06-05 09:49:49 -04:00
purian23 e0ab0a6b90 feat(keybinds): new mango overview workspace pill & keybind updates 2026-06-04 23:23:03 -04:00
purian23 713ce5f430 fix(display): unify compositor output profiles 2026-06-04 22:25:56 -04:00
purian23 8eb23bcc29 feat(mango): first-class MangoWM support across DMS, dankinstaller & UI tools
- Bring up Mango to parity with niri/hyprland via a native JSON-IPC w/Native MangoServic., replaces the legacy dwl/`mmsg` path and recent breaking changes
- Dankinstall: mango supported installer, config/binds templates, and packaging (Arch AUR, Fedora Terra auto-enable, Gentoo GURU)
- Window rules: Go provider + CLI + Settings GUI editor
- Keybinds + config reload on edit (mmsg dispatch reload_config)
- Misc new supported options in DMS settings
2026-06-04 18:45:04 -04:00
CoastlineSec 4181343ef3 feat(keybinds): add default app IPC actions (#2546) 2026-06-04 10:16:02 -04:00
bbedward d16566aa8d gh: add PR template 2026-06-04 10:15:44 -04:00
Nachum Barcohen 45eb101f40 fix(lock): wake monitors on input when powered off by lock (#2572)
The "power off monitors on lock" path relies on wake handlers
(MouseArea/Keys) at Lock.qml's root Scope, which sit outside the
WlSessionLock surface and never receive input while locked. The feature
only appeared to work on compositors that auto-restore output power on
input; compositors that fully tear down the output (e.g. MangoWC
disable_monitor) leave the screen off until blind-unlock.

Add a seat-level IdleMonitor (wlr idle-notify, surface-independent) in
IdleService that restores monitor power on any keyboard/pointer
activity. Gated on isShellLocked + monitorsOff + the setting, so it is
inert unless that path actually powered the monitors off.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:05:59 -04:00
purian23 59431869dc fix(keybinds): guard missing fields in cheatsheet search filter
Fixes #2116
2026-06-03 20:06:14 -04:00
purian23 6e7aca8b15 feat(window-rules): add niri default-floating-position rule
- Closes #2018
2026-06-03 19:43:23 -04:00
purian23 6f387b0481 fix(windowrules): honor $XDG_CONFIG_HOME for config paths
- Window rule read/write/includes detection and setup hardcoded
`$HOME/.config` so rules weren't applied when `XDG_CONFIG_HOME` pointed
elsewhere.
- Fixes #2289
2026-06-03 19:01:25 -04:00
purian23 82d4364032 feat(hyprland): add Resize on Border layout option 2026-06-03 18:45:33 -04:00
purian23 e3de54c941 feat(workspace): optionally group app icons on the active workspace
Closes #1208
2026-06-03 18:32:53 -04:00
purian23 6991b45fbe fix(settings): surface Windows Rules Tab for Hyprland
- small hyprland parser update
2026-06-03 17:39:44 -04:00
bbedward e5fff91ae6 notification: fix modal escape key handling in connected mode
fixes #2566
2026-06-03 10:49:51 -04:00
bbedward 2f2d4c9d9b i18n: remove redundant terms and sync 2026-06-03 10:32:51 -04:00
bbedward bfca1b46a6 greeter: support lua hyprland configs
fixes #2565
2026-06-03 09:52:45 -04:00
bbedward b117c80e47 settings: fix blur error message 2026-06-03 09:46:15 -04:00
purian23 d20aa3b80a feat(window-rules): view & convert external rules to DMS
- Read and convert external compositor rules into editable DMS rules
- Preserve niri multi-match rules and add match editor
- niri background-effect (blur/xray/noise/saturation) support
2026-06-03 08:59:51 -04:00
purian23 a34fda984d fix(Clipboard): stale image entry handling
- Resolved random DMS API errors & QML Warnings
2026-06-03 03:17:02 -04:00
purian23 510269dda9 fix(keybinds): show percentage amount in titles & performance improvements 2026-06-03 00:35:19 -04:00
purian23 d51b34797c refactor(Hyprland): updated the default close/kill keybinds for lua 2026-06-02 23:16:05 -04:00
arfan d2905072c0 feat(settings): Added Settings Tab Autostart App (XDG Autostart) (#2535)
* feat(Autostart): add Autostart tab and application selection popup

* fix(AutoStartTab): update systemdUserDir property to use XDG_CONFIG_HOME

* fix(AutoStartTab): update autostartDir and systemdUserDir to use StandardPaths for config home

* refactor(AutoStartTab): use FileView & FolderListModel

* refactor(AutoStartTab): implement systemd override generation for autostart applications using FileView

* feat(AutoStartTab): add systemd check to determine environment and update tray icon visibility

* feat(SettingsSidebar, AutoStartTab, DesktopService): add autostart functionality and systemd checks

* feat(AutoStartTab): add hidden property support for desktop entries and toggle functionality

* feat(AutoStartTab): add initialize autostart directory and add toast if writer failed

* add(AutoStartTab): logging for scoped log tracking

---------
2026-06-02 22:52:58 -04:00
purian23 1ee42506b6 refactor(control-center): consolidate detail section height rules 2026-06-02 15:48:47 -04:00
Guilherme Pagano 84fe2d751f fix(control-center): honor plugin ccDetailHeight instead of fixed 250px slot (#2559) 2026-06-02 15:31:55 -04:00
euletheia 5d0fc48706 Add DoNotDisturb & IdleInhibitor Indicators for the ControlCenterButton widget + Add optional icon to KeyboardLayoutName widget (#2513)
* feat(ControlCenterButton): add IdleInhibitor icon

* feat(ControlCenterButton): add DoNotDisturb icon

* fix(WidgetTabSection): adjust text width for the SystemTray Widget popup

* feat(KeyboardLayoutName): add optional icon

* refactor(KeyboardLayoutName): simplify icon visibility logic by using root.showIcon

---------
2026-06-02 14:25:35 -04:00
purian23 335c5b4ac5 feat(Greeter): add auto-login feature for startup settings
- Introduced a new cli flag:
`dms greeter sync --autologin-only`
and updated UI toggle in Greeter settings
2026-06-02 02:03:02 -04:00
bbedward 8c20f448ed control center: improve drag handling
misc: fix layer shell enum usage
2026-06-01 13:16:47 -04:00
jbwfu 0a668df138 fix(clipboard): prefer image MIME types over text fallbacks (#2551) 2026-06-01 11:44:10 -04:00
Guilherme Pagano 3e4d2b4d46 feat(control-center): add DiskUsage widget config overlay with showMountPath toggle and standardized tile sizing (#2507)
* feat(control-center): add widget config overlay with showMountPath toggle for DiskUsage

Introduces WidgetConfigOverlay and DiskUsageWidgetConfigMenu components, allowing
users to toggle mount path visibility per DiskUsage widget in edit mode

* refactor(control-center): use Theme.iconSizeLarge and Theme.fontSizeLarge for small tiles

Standardize SmallDiskUsageButton and SmallBatteryButton sizing with Theme.iconSizeLarge
and Theme.fontSizeLarge, and unify font weight to Font.Bold on both tile widgets.

* fix(control-center): adjust SmallDiskUsageButton font size based on showMountPath

* refactor(control-center): simplify DiskUsage config menu i18n strings

Remove the redundant "Disk Usage Widget" title and the toggle description
to reduce translatable strings
2026-06-01 11:35:14 -04:00
jbwfu 12e43d120e fix(clipboard): raise CLI IPC scanner limit for large entry payloads (#2550) 2026-06-01 11:01:34 -04:00
jbwfu a9845bf3cd fix(wallpaper): redraw wallpaper layers when fill mode changes (#2542) 2026-06-01 10:36:50 -04:00
Graeme Foster e51ceed175 fix(network): exclude virtual ether links and prune stale ones from networkd (#2505)
The networkd backend treated any link reporting Type=ether as a wired uplink.
Podman bridges and veth pairs report Type=ether, so they were classified as
ethernet: isWired() short-circuited on Type and never consulted looksVirtual(),
which also lacked a podman prefix.

The link map was also never pruned. Links discovered at enumeration or via
signals were kept forever, so torn-down container interfaces lingered as
routable and could win the wired-uplink slot over the real NIC -- leaving the
indicator showing WiFi while a wired connection was active and default-routed.

- isWired()/isWireless() exclude virtual interfaces before consulting Type, and
  looksVirtual() now recognises podman.
- enumerateLinks() reconciles the cached map against ListLinks via syncLinks(),
  pruning links that no longer appear so dead interfaces don't accumulate.
2026-06-01 09:45:49 -04:00
Karel "Angerion" Čeleda 304baf6f60 fix(CavaService): prevent 100% CPU EOF spin loop by using temp file (#2471)
* fix(CavaService): prevent 100% CPU EOF spin loop by using temp config file

* cava: make tmp file non-deterministic

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-06-01 09:45:16 -04:00
purian23 6b141a9b06 refactor(Hyprland): updates to Lua syntax/dispatchers 2026-06-01 09:31:19 -04:00
purian23 0c3659a612 feat(PluginBrowser): add sorting and filtering options for plugins
- Introduced sorting and filtering by installed, default, category, name, and author
2026-05-31 23:58:13 -04:00
purian23 a44bef5796 fix(Notifications): restore long message content from overflowing
- Addtional security escape patch
- Tweak Notification Center width
2026-05-31 22:38:50 -04:00
Youseffo13 b1ac6b0ef9 Add context to font weight keys, wrap render settings, fix uptime prefix (#2529)
* fix: i18n inconsistencies

* Update UserInfoCard.qml
2026-05-31 18:10:43 -04:00
purian23 98844a3b85 fix(Clipboard): prevent security risk HTML in window titles from fetching remote URLs
- Default StyledText to PlainText; keep RichText only on notification content
2026-05-31 17:41:55 -04:00
purian23 a32b8911c7 feat(Hyprland): add touchpad gesture support via DMS default configs 2026-05-31 15:45:17 -04:00
purian23 3118e7b9c3 fix(Hyprland): correct Lua keybind writes
- Write titles as Lua description metadata
- Use hl.dispatch for custom dispatcher actions
- Preserve legacy trailing comment titles on rewrite
- Update option edits before saving keybinds
2026-05-31 15:35:13 -04:00
Miguel Saliba 2ca2bc5fb8 fix: make matugen switch pywalfox dark/light mode (#2537)
Currently, when switching dark/light theme in DMS, pywalfox's mode does not get updated. This commit adds `pywalfox light/dark` to the matugen post_hook to fix that.
2026-05-31 00:35:06 -04:00
purian23 4bfb08f6ef fix(Hyprland): Lua config for display setup writes
- Check display include status on startup from legacy to lua
2026-05-31 00:11:30 -04:00
purian23 0689339780 feat(Hyprland): add fractional scaling display presets
- Show Hyprland scale presets that fit the active mode
- Preserve current dms setup values
2026-05-31 00:10:22 -04:00
purian23 a265625851 refactor(Hyprland): Update Lua migration and keybind writes
- emit native hl.dsp.* dispatchers for generated Lua keybinds
- keep legacy hyprland.conf installs read-only but preserved until dms setup migration
2026-05-30 23:07:06 -04:00
Body 389fffaf64 feat(Clipboard-Bar-Hist): Add search/filter to saved clipboard entries & animation states (#2464)
* Fix gaps and overlaps when filtering clipboard history

* feat(Clipboard-Bar-Hist): Add search/filter to saved clipboard entries as well. Change title on toggle between recent/saved.

* keep Pinned/Saved icon highlighted when selected

* add back filter animations

* Implement snap state for list views based on animation settings

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-05-30 15:10:58 -04:00
purian23 b7daf3f64a feat(ipc): add powerprofile status & shared profile helpers
- Follow-up to PR #2515
2026-05-30 14:57:01 -04:00
Huỳnh Thiện Lộc 461da22b08 feat(ipc): add native powerprofile target for power profiles management (#2515)
* feat: add native powerprofile IPC target for power profiles management

* feat: show centered PowerProfileModal with 3 square buttons for powerprofile IPC toggle

* style: enhance PowerProfileModal size, icons, description, and keyboard hints

* feat: add Space key binding to select highlighted power profile
2026-05-30 14:51:19 -04:00
jbwfu 2b661e241d fix(settings): support localized settings search (#2521) 2026-05-30 01:55:46 -04:00
Kyunghyun Park d7df3800c2 keybinds: add missing XF86 key mappings (#2500) 2026-05-30 01:31:58 -04:00
Bogdan f2961f9b6a feat(DiskUsage): updated dynamic width options for DiskUsage widget (#2517)
* feat(WidgetsTabSection.qml): added dynamic width and static padding for DiskUsage

* feat(DiskUsage.qml):added functionality for dynamic width and static padding, also changed spacing to work like in cpu and ram monitor. Now they look complete and same

* fix(DiskUsage): restore display modes & formatting

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-05-30 01:02:15 -04:00
purian23 f2d5ee4692 fix(animation): adjust the Popout/Control Center motion 2026-05-29 22:04:46 -04:00
purian23 7c2d5ce15e fix(Screenshot): allow region capture over shell overlays 2026-05-29 17:03:19 -04:00
Lucas 5ceb908b8b greeter: remove keep-max-bpc-unchanged option (#2528) 2026-05-29 15:14:41 -04:00
Paul d819865853 fix: Display Configurator in Hyprland (#2506)
* fix: display configurator

* fix: replace
2026-05-28 17:26:14 -04:00
jbwfu 38176ab543 fix(settings): make desktop widget group delete button clickable (#2512) 2026-05-28 12:19:02 -04:00
purian23 53936d7034 Revert "fix(IconTheme): apply stored icon theme at startup (#2511)"
This reverts commit aafc2ea4d7.
2026-05-28 11:42:07 -04:00
lingdianshiren aafc2ea4d7 fix(IconTheme): apply stored icon theme at startup (#2511)
Add applyStoredIconTheme() calls alongside existing applyStoredTheme()
calls in loadSettings(), onLoaded, and onLoadFailed, ensuring the stored
icon theme is synced to GTK/Qt/Cosmic configs on every startup and reload.

The applyStoredIconTheme() function and its _hooks entry already existed
in the codebase but were never invoked during initial settings loading.

Co-authored-by: lingdiansr <2077258365@qq.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:18:44 -04:00
Connor Welsh 8a4be4936a Hide MPRIS players that have nothing to play (#2509)
* fix(Mpris): exclude idle players from active-player selection

Add MprisController.isIdle() (player Stopped with empty title and
artist). _resolveActivePlayer excludes idle players from every
selection path, and re-resolves when the active player itself goes
idle. Existing triggers (availablePlayers change, isPlaying becoming
true) do not fire for a player merely stopping.

The fallback now gates on !isIdle(p) instead of p.canPlay. canPlay
describes whether Play() would succeed; !isIdle describes whether
there is anything to surface. canControl unchanged.

When no eligible player remains, activePlayer becomes null and
consumers that gate on it unload (the bar media widget via
WidgetHost; the dash via the companion fix).

* fix(DankDash): show no-player state when active player resolves to null

showNoPlayerNow gated on _noneAvailable (player count === 0) or
activePlayer being idle. Neither covers activePlayer being null
while players remain registered, which is now possible (an
always-on player sitting stopped with empty metadata).

Key showNoPlayerNow on !activePlayer; drop the unreachable
_trulyIdle.

* add(MprisController): add track artist change handling to active player resolution

---------
2026-05-28 00:19:34 -04:00
purian23 af097d0f33 feat(Greeter): Enhance login experience & manual username fallback support 2026-05-27 22:31:54 -04:00
Ethan Todd 44867e7b43 fix: awk in new greeter (#2508) 2026-05-27 22:02:37 -04:00
purian23 a366bf3ca0 fix(ClipboardEditor): Support legacy QT 6.xx decoding & large clipboard data 2026-05-26 16:38:32 -04:00
Huỳnh Thiện Lộc 89f86be00a feat: unify media controls dropdown interactions, hover behavior and cycle controls (#2470)
* feat: unify media controls dropdown interactions, hover behavior and cycle controls

- Implement hover-to-show and hover-to-hide for all media control dropdowns.
- Make clicking the Output Devices and Media Players buttons cycle through items when expanded.
- Always display the 'speaker' icon for Output Devices to maintain visual consistency.
- Bind dropdown player properties dynamically to fix list stale rendering states.

* fix(DankDash): use trackArtist property for artist label in MediaPlayerTab

* fix(DankDash): simplify active player label for consistency with output devices

* feat(DankDash): display volume levels for audio output devices in dropdown

* fix(DankDash): display Unknown Artist when artist is empty in player list

* feat(DankDash): add keyboard shortcuts for seeking, track cycling and playback control in Media popout

* feat(DankDash): change Up/Down arrow keys to adjust volume in Media popout

* feat(DankDash): auto-open volume dropdown overlay when using Up/Down shortcuts

* feat(DankDash): add Key M shortcut to toggle mute in Media popout

* fix(mpris): clamp minimum seek position to 0.1s to prevent browser player reset

* fix(mpris): cache stable length to prevent browser transient reset issues

* fix(mpris): persist activePlayerStableLength in MprisController singleton

* fix(mpris): resolve browser player album art with raw metadata and YouTube url fallbacks

* fix(mpris): resolve browser player album art with local caching and 16:9 youtube fallbacks

* style(mpris): trim trailing whitespace in TrackArtService

* fix(mpris): address code review feedback on remote caching, stale artwork, and hover state

* fix: secure curl commands and prevent premature dropdown overlays closing on button re-hover
2026-05-26 13:44:51 -04:00
bbedward 12a744e985 clipboard: fix editing in popout 2026-05-26 11:49:14 -04:00
Guilherme Pagano 54f272ba1e fix(toast): align dimensions to whole pixels to avoid blurry rendering (#2494)
The toast Rectangle uses `layer.enabled: true`, which renders to a
texture before compositing. With fractional implicit/content sizes
(derived from text and icon metrics), the cached texture was being
sampled with sub-pixel interpolation and the toast looked blurry
under fractional-scale-aware compositors (e.g., niri).

Wrap toastWidth/toastHeight and implicitWidth/implicitHeight with
Theme.px(value, dpr), matching the alignment NotificationPopup.qml
already applies to its surface.
2026-05-26 11:23:10 -04:00
Cong Luan Tran 60b64f22c6 fix(BatteryService): Make bluetoothBattery detection actually work (#2486) 2026-05-26 11:22:39 -04:00
Niltempus 97666dc73d Wait for location capability before requesting state (#2476) 2026-05-26 11:16:42 -04:00
bbedward 6c6756936b i18n: sync 2026-05-26 11:09:06 -04:00
purian23 91f8ca4efe ci: upgrade prek-action to v2 2026-05-26 09:06:26 -04:00
purian23 045ac59a44 feat(Clipboard): Clipboard Editor PR Revived (#2492)
* feat(clipboard): Add editing capability to clipboard entries
* Add split save menu for clipboard editor
* Add clipboard editor shortcuts and hints
* Show full clipboard text in editor
* feat(Clipboard): Revive ClipboardEditor PR

- Original PR #1916 by @nabaco
* fix(clipboard): restore Save button targets in editor

---------

Co-authored-by: Nachum Barcohen <38861757+nabaco@users.noreply.github.com>
2026-05-25 23:25:57 -04:00
purian23 078180fe42 feat(Greeter): improved multi-user UI and per-user theme sync
- Introduce multi-account greeter login with per-user theme previews
- Add `dms greeter sync --profile` for secondary users with or without sudo
- Add Manage greeter group membership from Settings UI → Users Tab
2026-05-25 22:41:23 -04:00
287 changed files with 41219 additions and 10470 deletions
+31
View File
@@ -0,0 +1,31 @@
## Description
<!-- What does this PR do and why? -->
## Type of change
<!-- Check all that apply. -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that changes existing behavior)
- [ ] Refactor / internal cleanup
- [ ] Documentation
- [ ] Other
## Related issues
<!-- e.g. "Fixes #123", "Closes #123". Leave blank if none. -->
## Screenshots / video
<!-- Include screenshots or a video for any user-facing or visual change. -->
## Checklist
- [ ] My code follows the conventions in CONTRIBUTING.md
- [ ] I have tested my changes locally
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
go-version-file: core/go.mod go-version-file: core/go.mod
- name: run pre-commit hooks - name: run pre-commit hooks
uses: j178/prek-action@v1 uses: j178/prek-action@v2
+2 -2
View File
@@ -42,7 +42,7 @@ configure passwordless sudo for your user.`,
} }
func init() { func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)") rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)") rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)") rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation") rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
@@ -95,7 +95,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
func runHeadless() error { func runHeadless() error {
// Validate required flags // Validate required flags
if compositor == "" { if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)") return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
} }
if term == "" { if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)") return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
+11 -12
View File
@@ -56,6 +56,8 @@ func init() {
type IncludeResult struct { type IncludeResult struct {
Exists bool `json:"exists"` Exists bool `json:"exists"`
Included bool `json:"included"` Included bool `json:"included"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
func runResolveInclude(cmd *cobra.Command, args []string) { func runResolveInclude(cmd *cobra.Command, args []string) {
@@ -85,10 +87,7 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
} }
func checkHyprlandInclude(filename string) (IncludeResult, error) { func checkHyprlandInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
@@ -106,6 +105,8 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainLua := filepath.Join(configDir, "hyprland.lua") mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil { if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.ReadOnly = false
processedLua := make(map[string]bool) processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) { if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true result.Included = true
@@ -115,6 +116,10 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainConf := filepath.Join(configDir, "hyprland.conf") mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil { if _, err := os.Stat(mainConf); err == nil {
if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool) processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) { if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true result.Included = true
@@ -183,10 +188,7 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
} }
func checkNiriInclude(filename string) (IncludeResult, error) { func checkNiriInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/niri") configDir := filepath.Join(utils.XDGConfigHome(), "niri")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
@@ -262,10 +264,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
} }
func checkMangoWCInclude(filename string) (IncludeResult, error) { func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/mango") configDir := filepath.Join(utils.XDGConfigHome(), "mango")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
+105 -1
View File
@@ -125,6 +125,7 @@ const (
catConfigFiles catConfigFiles
catServices catServices
catEnvironment catEnvironment
catFonts
) )
func (c category) String() string { func (c category) String() string {
@@ -147,6 +148,8 @@ func (c category) String() string {
return "Services" return "Services"
case catEnvironment: case catEnvironment:
return "Environment" return "Environment"
case catFonts:
return "Fonts"
default: default:
return "Unknown" return "Unknown"
} }
@@ -213,6 +216,7 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkConfigurationFiles(), checkConfigurationFiles(),
checkSystemdServices(), checkSystemdServices(),
checkEnvironmentVars(), checkEnvironmentVars(),
checkFonts(),
) )
switch { switch {
@@ -947,9 +951,12 @@ func checkSystemdServices() []checkResult {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active) message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
} }
switch { switch {
case dmsState.active == "failed":
status = statusError
case dmsState.active == "active":
case dmsState.enabled == "disabled": case dmsState.enabled == "disabled":
status, message = statusWarn, "Disabled" status, message = statusWarn, "Disabled"
case dmsState.active == "failed" || dmsState.active == "inactive": case dmsState.active == "inactive":
status = statusError status = statusError
} }
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
@@ -1132,3 +1139,100 @@ func formatResultsPlain(results []checkResult) string {
return sb.String() return sb.String()
} }
func checkFonts() []checkResult {
var results []checkResult
url := doctorDocsURL + "#fonts"
configDir, err := os.UserConfigDir()
if err != nil {
return nil
}
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
fontFamily := "Inter Variable"
monoFontFamily := "Fira Code"
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
}
}
if !utils.CommandExists("fc-list") {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
return results
}
// Retrieve font list
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
return results
}
outStr := string(output)
if len(strings.TrimSpace(outStr)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
return results
}
lowerFonts := strings.ToLower(outStr)
// Helper to check if a font exists
hasFont := func(name string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for _, line := range strings.Split(lowerFonts, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Each line can have comma-separated families
families := strings.Split(line, ",")
for _, fam := range families {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
// Normal Font Check
if hasFont(fontFamily) {
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Normal Font", statusWarn,
fmt.Sprintf("'%s' not found", fontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
// Monospace Font Check
if hasFont(monoFontFamily) {
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Monospace Font", statusWarn,
fmt.Sprintf("'%s' not found", monoFontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
return results
}
+111 -9
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -61,20 +62,34 @@ var greeterInstallCmd = &cobra.Command{
var greeterSyncCmd = &cobra.Command{ var greeterSyncCmd = &cobra.Command{
Use: "sync", Use: "sync",
Short: "Sync DMS theme and settings with greeter", Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
PreRunE: preRunPrivileged, PreRunE: func(cmd *cobra.Command, args []string) error {
profile, _ := cmd.Flags().GetBool("profile")
if profile {
return nil
}
return preRunPrivileged(cmd, args)
},
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth") auth, _ := cmd.Flags().GetBool("auth")
local, _ := cmd.Flags().GetBool("local") local, _ := cmd.Flags().GetBool("local")
profile, _ := cmd.Flags().GetBool("profile")
autologinOnly, _ := cmd.Flags().GetBool("autologin")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
if term { if term {
if err := syncInTerminal(yes, auth, local); err != nil { if err := syncInTerminal(yes, auth, local, profile, autologinOnly); err != nil {
log.Fatalf("Error launching sync in terminal: %v", err) log.Fatalf("Error launching sync in terminal: %v", err)
} }
return return
} }
if err := syncGreeter(yes, auth, local); err != nil { if autologinOnly {
if err := syncGreeterAutoLoginOnly(yes); err != nil {
log.Fatalf("Error syncing greeter auto-login: %v", err)
}
return
}
if err := syncGreeter(yes, auth, local, profile); err != nil {
log.Fatalf("Error syncing greeter: %v", err) log.Fatalf("Error syncing greeter: %v", err)
} }
}, },
@@ -85,6 +100,8 @@ func init() {
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done") greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles") greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles")
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path") greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
greeterSyncCmd.Flags().Bool("autologin", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
} }
var greeterEnableCmd = &cobra.Command{ var greeterEnableCmd = &cobra.Command{
@@ -512,8 +529,8 @@ func runCommandInTerminal(shellCmd string) error {
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)") return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
} }
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error { func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool, autologinOnly bool) error {
syncFlags := make([]string, 0, 3) syncFlags := make([]string, 0, 5)
if nonInteractive { if nonInteractive {
syncFlags = append(syncFlags, "--yes") syncFlags = append(syncFlags, "--yes")
} }
@@ -523,11 +540,22 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
if local { if local {
syncFlags = append(syncFlags, "--local") syncFlags = append(syncFlags, "--local")
} }
if profileOnly {
syncFlags = append(syncFlags, "--profile")
}
if autologinOnly {
syncFlags = append(syncFlags, "--autologin")
}
shellSyncCmd := "dms greeter sync" shellSyncCmd := "dms greeter sync"
if len(syncFlags) > 0 { if len(syncFlags) > 0 {
shellSyncCmd += " " + strings.Join(syncFlags, " ") shellSyncCmd += " " + strings.Join(syncFlags, " ")
} }
shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` var shellCmd string
if autologinOnly {
shellCmd = shellSyncCmd + `; echo; echo "Auto-login update finished. Closing in 3 seconds..."; sleep 3`
} else {
shellCmd = shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
}
return runCommandInTerminal(shellCmd) return runCommandInTerminal(shellCmd)
} }
@@ -541,7 +569,54 @@ func resolveLocalWrapperShell() (string, error) {
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper") return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
} }
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { func syncGreeterAutoLoginOnly(nonInteractive bool) error {
logFunc := func(msg string) {
fmt.Println(msg)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
cacheSettingsPath := filepath.Join(greeter.GreeterCacheDir, "settings.json")
enabled := false
for _, path := range []string{cacheSettingsPath, settingsPath} {
data, readErr := os.ReadFile(path)
if readErr != nil {
continue
}
var cfg struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
}
if json.Unmarshal(data, &cfg) == nil {
enabled = cfg.GreeterAutoLogin
break
}
}
fmt.Println("=== Greeter Auto-Login ===")
fmt.Println()
if enabled {
fmt.Println("Enabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, DMS will skip the greeter password until you sign out.")
} else {
fmt.Println("Disabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, you will enter your password at the greeter again.")
}
fmt.Println()
fmt.Println("Administrator (sudo) access is required to update /etc/greetd/config.toml.")
fmt.Println()
return greeter.SyncGreeterAutoLoginOnly(logFunc, "")
}
func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
if profileOnly {
return syncGreeterProfileOnly(nonInteractive)
}
if !nonInteractive { if !nonInteractive {
fmt.Println("=== DMS Greeter Sync ===") fmt.Println("=== DMS Greeter Sync ===")
fmt.Println() fmt.Println()
@@ -752,6 +827,26 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
return nil return nil
} }
func syncGreeterProfileOnly(nonInteractive bool) error {
logFunc := func(msg string) {
fmt.Println(msg)
}
if !nonInteractive {
fmt.Println("=== DMS Greeter Profile Sync ===")
fmt.Println()
fmt.Println("Syncing your personal greeter theme slot (no system changes)...")
}
if err := greeter.SyncUserProfileCache(logFunc); err != nil {
return err
}
if !nonInteractive {
fmt.Println("\n=== Profile Sync Complete ===")
fmt.Println("\nYour theme, wallpaper, and profile photo have been synced for the login screen.")
fmt.Println("Log out to preview your greeter look when selecting your account.")
}
return nil
}
func hasDmsShellQml(dir string) bool { func hasDmsShellQml(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "shell.qml")) info, err := os.Stat(filepath.Join(dir, "shell.qml"))
return err == nil && !info.IsDir() return err == nil && !info.IsDir()
@@ -837,7 +932,14 @@ func resolveLocalDMSPath() (string, error) {
} }
} }
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd) configuredCommand := readDefaultSessionCommand("/etc/greetd/config.toml")
if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" {
if resolved, ok := resolveDMSLocalCandidate(pathOverride); ok {
return resolved, nil
}
}
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root, set DMS_LOCAL_PATH=/absolute/path/to/repo, or configure greetd with -p /path/to/quickshell", wd)
} }
func disableDisplayManager(dmName string) (bool, error) { func disableDisplayManager(dmName string) (bool, error) {
+34 -2
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
@@ -179,9 +181,39 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
return config return config
} }
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
func setPopoutScreenshotMode(begin bool) {
fn := "end"
if begin {
fn = "begin"
}
cmdArgs := []string{"ipc"}
if pid, ok := getFirstDMSPID(); ok {
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := findConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Run()
}
func runScreenshot(config screenshot.Config) { func runScreenshot(config screenshot.Config) {
sc := screenshot.New(config) // Region select needs the keyboard; drop popout grabs for its duration.
result, err := sc.Run() result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
+42 -6
View File
@@ -102,32 +102,42 @@ var setupWindowrulesCmd = &cobra.Command{
type dmsConfigSpec struct { type dmsConfigSpec struct {
niriFile string niriFile string
hyprFile string hyprFile string
mangoFile string
niriContent func(terminal string) string niriContent func(terminal string) string
hyprContent func(terminal string) string hyprContent func(terminal string) string
mangoContent func(terminal string) string
} }
var dmsConfigSpecs = map[string]dmsConfigSpec{ var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": { "binds": {
niriFile: "binds.kdl", niriFile: "binds.kdl",
hyprFile: "binds.lua", hyprFile: "binds.lua",
mangoFile: "binds.conf",
niriContent: func(t string) string { niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
}, },
hyprContent: func(t string) string { hyprContent: func(t string) string {
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
}, },
mangoContent: func(t string) string {
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
}, },
"layout": { "layout": {
niriFile: "layout.kdl", niriFile: "layout.kdl",
hyprFile: "layout.lua", hyprFile: "layout.lua",
mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
hyprFile: "colors.lua", hyprFile: "colors.lua",
mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return config.MangoColorsConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
@@ -136,20 +146,26 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
"outputs": { "outputs": {
niriFile: "outputs.kdl", niriFile: "outputs.kdl",
hyprFile: "outputs.lua", hyprFile: "outputs.lua",
mangoFile: "outputs.conf",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig }, hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
mangoContent: func(_ string) string { return "" },
}, },
"cursor": { "cursor": {
niriFile: "cursor.kdl", niriFile: "cursor.kdl",
hyprFile: "cursor.lua", hyprFile: "cursor.lua",
mangoFile: "cursor.conf",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig }, hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
mangoContent: func(_ string) string { return "" },
}, },
"windowrules": { "windowrules": {
niriFile: "windowrules.kdl", niriFile: "windowrules.kdl",
hyprFile: "windowrules.lua", hyprFile: "windowrules.lua",
mangoFile: "windowrules.conf",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig }, hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
mangoContent: func(_ string) string { return "" },
}, },
} }
@@ -192,7 +208,7 @@ func detectCompositorForSetup() (string, error) {
switch len(compositors) { switch len(compositors) {
case 0: case 0:
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)") return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
case 1: case 1:
return strings.ToLower(compositors[0]), nil return strings.ToLower(compositors[0]), nil
} }
@@ -224,6 +240,9 @@ func runSetupDmsConfig(name string) error {
case "hyprland": case "hyprland":
filename = spec.hyprFile filename = spec.hyprFile
contentFn = spec.hyprContent contentFn = spec.hyprContent
case "mango", "mangowc":
filename = spec.mangoFile
contentFn = spec.mangoContent
default: default:
return fmt.Errorf("unsupported compositor: %s", compositor) return fmt.Errorf("unsupported compositor: %s", compositor)
} }
@@ -235,9 +254,11 @@ func runSetupDmsConfig(name string) error {
var dmsDir string var dmsDir string
switch compositor { switch compositor {
case "niri": case "niri":
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
case "hyprland": case "hyprland":
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc":
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
} }
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -273,7 +294,14 @@ func runSetup() error {
wm, wmSelected := promptCompositor() wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal() terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd() useSystemd := true
if wmSelected {
if wm == deps.WindowManagerMango {
useSystemd = false
} else {
useSystemd = promptSystemd()
}
}
if !wmSelected && !terminalSelected { if !wmSelected && !terminalSelected {
fmt.Println("No configurations selected. Exiting.") fmt.Println("No configurations selected. Exiting.")
@@ -379,10 +407,11 @@ func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:") fmt.Println("Select compositor:")
fmt.Println("1) Niri") fmt.Println("1) Niri")
fmt.Println("2) Hyprland") fmt.Println("2) Hyprland")
fmt.Println("3) None") fmt.Println("3) Mango")
fmt.Println("4) None")
var response string var response string
fmt.Print("\nChoice (1-3): ") fmt.Print("\nChoice (1-4): ")
fmt.Scanln(&response) fmt.Scanln(&response)
response = strings.TrimSpace(response) response = strings.TrimSpace(response)
@@ -391,6 +420,8 @@ func promptCompositor() (deps.WindowManager, bool) {
return deps.WindowManagerNiri, true return deps.WindowManagerNiri, true
case "2": case "2":
return deps.WindowManagerHyprland, true return deps.WindowManagerHyprland, true
case "3":
return deps.WindowManagerMango, true
default: default:
return deps.WindowManagerNiri, false return deps.WindowManagerNiri, false
} }
@@ -447,6 +478,11 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"), filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"), filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
} }
case deps.WindowManagerMango:
configPaths = []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
} }
for _, configPath := range configPaths { for _, configPath := range configPaths {
+49 -22
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -26,7 +27,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -40,7 +41,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -54,7 +55,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3), Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -68,7 +69,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -82,7 +83,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -120,6 +121,9 @@ func getCompositor(args []string) string {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland" return "hyprland"
} }
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
return "mango"
}
return "" return ""
} }
@@ -139,17 +143,14 @@ func writeRuleSuccess(id, path string) {
func runWindowrulesList(cmd *cobra.Command, args []string) { func runWindowrulesList(cmd *cobra.Command, args []string) {
compositor := getCompositor(args) compositor := getCompositor(args)
if compositor == "" { if compositor == "" {
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri") log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
} }
var result WindowRulesListResult var result WindowRulesListResult
switch compositor { switch compositor {
case "niri": case "niri":
configDir, err := utils.ExpandPath("$HOME/.config/niri") configDir := filepath.Join(utils.XDGConfigHome(), "niri")
if err != nil {
log.Fatalf("Failed to expand niri config path: %v", err)
}
parseResult, err := providers.ParseNiriWindowRules(configDir) parseResult, err := providers.ParseNiriWindowRules(configDir)
if err != nil { if err != nil {
@@ -182,10 +183,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus result.DMSStatus = parseResult.DMSStatus
case "hyprland": case "hyprland":
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err)
}
parseResult, err := providers.ParseHyprlandWindowRules(configDir) parseResult, err := providers.ParseHyprlandWindowRules(configDir)
if err != nil { if err != nil {
@@ -217,6 +215,38 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.Rules = allRules result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil {
log.Fatalf("Failed to parse mango window rules: %v", err)
}
allRules := providers.ConvertMangoRulesToWindowRules(parseResult.Rules)
provider := providers.NewMangoWritableProvider(configDir)
dmsRules, _ := provider.LoadDMSRules()
dmsRuleMap := make(map[int]windowrules.WindowRule)
for i, dr := range dmsRules {
dmsRuleMap[i] = dr
}
dmsIdx := 0
for i, r := range allRules {
if r.Source == "dms/windowrules.conf" {
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
allRules[i].ID = dmr.ID
allRules[i].Name = dmr.Name
}
dmsIdx++
}
}
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
default: default:
log.Fatalf("Unknown compositor: %s", compositor) log.Fatalf("Unknown compositor: %s", compositor)
} }
@@ -315,17 +345,14 @@ func runWindowrulesReorder(cmd *cobra.Command, args []string) {
func getWindowRulesProvider(compositor string) windowrules.WritableProvider { func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
switch compositor { switch compositor {
case "niri": case "niri":
configDir, err := utils.ExpandPath("$HOME/.config/niri") configDir := filepath.Join(utils.XDGConfigHome(), "niri")
if err != nil {
return nil
}
return providers.NewNiriWritableProvider(configDir) return providers.NewNiriWritableProvider(configDir)
case "hyprland": case "hyprland":
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
if err != nil {
return nil
}
return providers.NewHyprlandWritableProvider(configDir) return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir)
default: default:
return nil return nil
} }
+6
View File
@@ -12,6 +12,10 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
) )
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
// overhead in the line-delimited IPC response.
const maxIPCMessageSize = 96 * 1024 * 1024
func sendServerRequest(req models.Request) (*models.Response[any], error) { func sendServerRequest(req models.Request) (*models.Response[any], error) {
socketPath := getServerSocketPath() socketPath := getServerSocketPath()
@@ -22,6 +26,7 @@ func sendServerRequest(req models.Request) (*models.Response[any], error) {
defer conn.Close() defer conn.Close()
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req) reqData, err := json.Marshal(req)
@@ -61,6 +66,7 @@ func sendServerRequestFireAndForget(req models.Request) error {
defer conn.Close() defer conn.Close()
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req) reqData, err := json.Marshal(req)
+152 -6
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
@@ -192,6 +194,7 @@ func runShellInteractive(session bool) {
} }
}() }()
ensureFontCache()
log.Infof("Spawning quickshell with -p %s", configPath) log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
@@ -227,8 +230,10 @@ func runShellInteractive(session bool) {
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr tracker := &stderrTracker{parent: os.Stderr}
cmd.Stderr = tracker
startTime := time.Now()
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
log.Fatalf("Error starting quickshell: %v", err) log.Fatalf("Error starting quickshell: %v", err)
} }
@@ -277,7 +282,9 @@ func runShellInteractive(session bool) {
case <-errChan: case <-errChan:
cancel() cancel()
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
} }
@@ -294,7 +301,9 @@ func runShellInteractive(session bool) {
cmd.Process.Signal(syscall.SIGTERM) cmd.Process.Signal(syscall.SIGTERM)
} }
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
} }
} }
} }
@@ -434,6 +443,7 @@ func runShellDaemon(session bool) {
} }
}() }()
ensureFontCache()
log.Infof("Spawning quickshell with -p %s", configPath) log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
@@ -478,8 +488,10 @@ func runShellDaemon(session bool) {
cmd.Stdin = devNull cmd.Stdin = devNull
cmd.Stdout = devNull cmd.Stdout = devNull
cmd.Stderr = devNull tracker := &stderrTracker{parent: devNull}
cmd.Stderr = tracker
startTime := time.Now()
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err) log.Fatalf("Error starting daemon: %v", err)
} }
@@ -528,7 +540,9 @@ func runShellDaemon(session bool) {
case <-errChan: case <-errChan:
cancel() cancel()
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
} }
@@ -543,7 +557,9 @@ func runShellDaemon(session bool) {
cmd.Process.Signal(syscall.SIGTERM) cmd.Process.Signal(syscall.SIGTERM)
} }
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
} }
} }
} }
@@ -748,3 +764,133 @@ func printIPCHelp() {
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", ")) fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
} }
} }
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
func ensureFontCache() {
if _, err := exec.LookPath("fc-list"); err != nil {
return
}
if _, err := exec.LookPath("fc-cache"); err != nil {
return
}
var fontsToCheck []string
if configDir, err := os.UserConfigDir(); err == nil {
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" && settings.FontFamily != "Inter Variable" {
fontsToCheck = append(fontsToCheck, settings.FontFamily)
}
if settings.MonoFontFamily != "" && settings.MonoFontFamily != "Fira Code" {
fontsToCheck = append(fontsToCheck, settings.MonoFontFamily)
}
}
}
}
if len(fontsToCheck) == 0 {
return
}
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil || len(strings.TrimSpace(string(output))) == 0 {
log.Warnf("Font cache appears empty or corrupt, rebuilding...")
rebuildFontCache()
return
}
cacheFonts := strings.ToLower(string(output))
var missing []string
for _, font := range fontsToCheck {
if !fontInCache(strings.ToLower(font), cacheFonts) {
missing = append(missing, font)
}
}
if len(missing) > 0 {
log.Warnf("Font(s) not found in cache: %s — rebuilding...", strings.Join(missing, ", "))
rebuildFontCache()
}
}
func fontInCache(target, cache string) bool {
for _, line := range strings.Split(cache, "\n") {
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
func rebuildFontCache() {
cmd := exec.Command("fc-cache", "-f")
if output, err := cmd.CombinedOutput(); err != nil {
log.Warnf("Failed to rebuild font cache: %v\n%s", err, string(output))
} else {
log.Infof("Font cache rebuilt successfully")
}
}
type stderrTracker struct {
mu sync.Mutex
buf strings.Builder
parent io.Writer
}
func (s *stderrTracker) Write(p []byte) (n int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.buf.Len() < 8192 {
s.buf.Write(p)
}
if s.parent != nil {
return s.parent.Write(p)
}
return len(p), nil
}
func (s *stderrTracker) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.String()
}
// logStartupFailure logs diagnostic advice if qs crashes within 5s of launch.
func logStartupFailure(startTime time.Time, exitCode int, tracker *stderrTracker) {
if time.Since(startTime) >= 5*time.Second || exitCode == 0 || exitCode > 128 {
return
}
if containsFontCrashSignature(tracker.String()) {
log.Errorf("DMS startup failed due to a potential font/rendering crash. Try running 'fc-cache -fv' and restarting DMS.")
} else {
log.Errorf("DMS startup failed (exit code %d). Run 'dms doctor' for more diagnostics.", exitCode)
}
}
func containsFontCrashSignature(logStr string) bool {
logStr = strings.ToLower(logStr)
signatures := []string{
"fontconfig",
"freetype",
"ft_load_glyph",
"ft_face",
"fc-list",
"fc-cache",
"glyph",
"typeface",
}
for _, sig := range signatures {
if strings.Contains(logStr, sig) {
return true
}
}
return false
}
+106
View File
@@ -73,6 +73,10 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"), filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
}, },
"Mango": {
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf"),
},
"Ghostty": { "Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"), filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
}, },
@@ -126,6 +130,14 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err) return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
} }
} }
case deps.WindowManagerMango:
if shouldReplaceConfig("Mango") {
result, err := cd.deployMangoConfig(terminal, useSystemd)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Mango config: %w", err)
}
}
} }
switch terminal { switch terminal {
@@ -269,6 +281,96 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
return nil return nil
} }
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Mango",
Path: filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty"
}
// DMS owns config.conf for mango (like niri/hyprland): back up and replace.
if existingData, err := os.ReadFile(result.Path); err == nil {
cd.log("Found existing Mango configuration")
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Mango configuration")
return result, nil
}
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string) error {
configs := []struct {
name string
content string
overwrite bool
}{
// binds.conf is DMS-owned (overwrite); the rest are runtime/user-managed.
{"binds.conf", strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand), true},
{"colors.conf", MangoColorsConfig, false},
{"layout.conf", MangoLayoutConfig, false},
{"outputs.conf", "", false},
{"cursor.conf", "", false},
{"windowrules.conf", "", false},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
if !cfg.overwrite {
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
}
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) { func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult var results []DeploymentResult
@@ -600,6 +702,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
cd.log(fmt.Sprintf(format, v...))
})
result.Deployed = true result.Deployed = true
cd.log("Successfully deployed Hyprland configuration") cd.log("Successfully deployed Hyprland configuration")
return result, nil return result, nil
+27 -3
View File
@@ -20,13 +20,17 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir() td := t.TempDir()
t.Setenv("HOME", td) t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr") configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755)) dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf") confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644)) require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil) CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated") assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName)) assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
}) })
@@ -34,20 +38,25 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir() td := t.TempDir()
t.Setenv("HOME", td) t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr") configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755)) dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua") luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644)) require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf") confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644)) require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil) CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath) assert.NoFileExists(t, confPath)
assert.NoFileExists(t, dmsConfPath)
assert.FileExists(t, luaPath) assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName)) entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err) require.NoError(t, err)
require.Len(t, entries, 1) require.Len(t, entries, 1)
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf")) assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
}) })
} }
@@ -404,6 +413,7 @@ general {
dmsDir := filepath.Join(td, ".config", "hypr", "dms") dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755)) require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
@@ -423,10 +433,12 @@ general {
assert.Contains(t, result.BackupPath, hyprlandBackupDirName) assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath) assert.NoFileExists(t, hyprPath)
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf")) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf")) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old")) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old")) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf")) assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf")) assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old")) assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old")) assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
@@ -485,7 +497,7 @@ general {
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua")) managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`) assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`) assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua")) user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err) require.NoError(t, err)
@@ -508,6 +520,18 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandLuaConfig, "input =") assert.Contains(t, HyprlandLuaConfig, "input =")
} }
func TestMangoConfigStructure(t *testing.T) {
assert.Contains(t, MangoConfig, "exec-once=dms run")
assert.NotContains(t, MangoConfig, "exec_once=dms run")
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,K,focusdir,up")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,L,focusdir,right")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,right,3,viewtoleft_have_client")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,left,3,viewtoright_have_client")
}
func TestGhosttyConfigStructure(t *testing.T) { func TestGhosttyConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyConfig, "window-decoration = false") assert.Contains(t, GhosttyConfig, "window-decoration = false")
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0") assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
+10 -6
View File
@@ -11,6 +11,7 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper")) hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview")) hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle")) hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet -- === Cheat sheet
@@ -38,7 +39,7 @@ hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increme
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true }) hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management === -- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.kill()) hl.bind("SUPER + Q", hl.dsp.window.close())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })) hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" })) hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
@@ -112,6 +113,9 @@ hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" })) hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" })) hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Touchpad Gestures ===
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- === Numbered Workspaces === -- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" })) hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" })) hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
@@ -140,7 +144,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout === -- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit")) hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]])) hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" }))
-- === Move/resize windows with mainMod + LMB/RMB and dragging === -- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" }) hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
@@ -150,10 +154,10 @@ hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = tr
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" }) hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing === -- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true }) hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true }) hl.bind("SUPER + equal", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true }) hl.bind("SUPER + SHIFT + minus", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true }) hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true })
-- === Screenshots === -- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot")) hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
@@ -13,6 +13,10 @@ hl.config({
input = { input = {
kb_layout = "us", kb_layout = "us",
numlock_by_default = true, numlock_by_default = true,
touchpad = {
tap_to_click = true,
natural_scroll = true,
},
}, },
general = { general = {
gaps_in = 5, gaps_in = 5,
@@ -0,0 +1,140 @@
# DMS default keybinds (MangoWM) — managed by DMS, regenerated by `dms setup`.
# Format: bind=MODS,key,action[,args]
# Put bind descriptions above bind lines; inline # comments break Mango spawn args.
# === Application Launchers ===
# Open Terminal
bind=SUPER,t,spawn,{{TERMINAL_COMMAND}}
# Open Terminal
bind=SUPER,Return,spawn,{{TERMINAL_COMMAND}}
# Application Launcher
bind=SUPER,space,spawn,dms ipc call spotlight toggle
# Spotlight Bar
bind=ALT,space,spawn,dms ipc call spotlight-bar toggle
# Clipboard Manager
bind=SUPER,v,spawn,dms ipc call clipboard toggle
# Task Manager
bind=SUPER,m,spawn,dms ipc call processlist focusOrToggle
# Settings
bind=SUPER,comma,spawn,dms ipc call settings focusOrToggle
# Notification Center
bind=SUPER,n,spawn,dms ipc call notifications toggle
# Notepad
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
# Browse Wallpapers
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
# Power Menu
bind=SUPER,x,spawn,dms ipc call powermenu toggle
# Cycle Display Profile
bind=SUPER,p,spawn,dms ipc outputs cycleProfile
# === Cheat sheet ===
# Keyboard Shortcuts
bind=SUPER+SHIFT,slash,spawn,dms ipc call keybinds toggle mangowc
# === Security ===
# Lock Screen
bind=SUPER+ALT,l,spawn,dms ipc call lock lock
# Task Manager
bind=CTRL+ALT,Delete,spawn,dms ipc call processlist focusOrToggle
# === Window Rules ===
# Create Window Rule
bind=SUPER+SHIFT,w,spawn,dms ipc call window-rules toggle
# === Screenshots ===
# Screenshot: Interactive
bind=none,Print,spawn,dms screenshot
# Screenshot: Full Screen
bind=CTRL,Print,spawn,dms screenshot full
# Screenshot: Window
bind=ALT,Print,spawn,dms screenshot window
# === Audio Controls ===
bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3
bind=none,XF86AudioLowerVolume,spawn,dms ipc call audio decrement 3
bind=none,XF86AudioMute,spawn,dms ipc call audio mute
bind=none,XF86AudioMicMute,spawn,dms ipc call audio micmute
bind=none,XF86AudioPlay,spawn,dms ipc call mpris playPause
bind=none,XF86AudioPause,spawn,dms ipc call mpris playPause
bind=none,XF86AudioPrev,spawn,dms ipc call mpris previous
bind=none,XF86AudioNext,spawn,dms ipc call mpris next
# === Brightness Controls ===
bind=none,XF86MonBrightnessUp,spawn,dms ipc call brightness increment 5
bind=none,XF86MonBrightnessDown,spawn,dms ipc call brightness decrement 5
# === Window Management ===
# Close Window
bind=SUPER,q,killclient,
bind=SUPER,f,togglefullscreen,
bind=SUPER,a,togglemaximizescreen,
bind=SUPER+SHIFT,space,togglefloating,
bind=SUPER,o,toggleoverview
bind=ALT,Tab,toggleoverview
# Exit Compositor
bind=SUPER+SHIFT,e,quit,
# === Focus Navigation ===
bind=SUPER,Tab,focusstack,next
bind=SUPER+SHIFT,Tab,focusstack,prev
bind=SUPER,Left,focusdir,left
bind=SUPER,H,focusdir,left
bind=SUPER,Right,focusdir,right
bind=SUPER,L,focusdir,right
bind=SUPER,Up,focusdir,up
bind=SUPER,K,focusdir,up
bind=SUPER,Down,focusdir,down
bind=SUPER,J,focusdir,down
# === Window Movement ===
bind=SUPER+SHIFT,Left,exchange_client,left
bind=SUPER+SHIFT,Right,exchange_client,right
bind=SUPER+SHIFT,Up,exchange_client,up
bind=SUPER+SHIFT,Down,exchange_client,down
bind=SUPER+SHIFT,H,exchange_client,left
bind=SUPER+SHIFT,L,exchange_client,right
bind=SUPER+SHIFT,K,exchange_client,up
bind=SUPER+SHIFT,J,exchange_client,down
# === Monitor Navigation ===
bind=SUPER+ALT,Left,focusmon,left
bind=SUPER+ALT,Right,focusmon,right
bind=SUPER+ALT+SHIFT,Left,tagmon,left
bind=SUPER+ALT+SHIFT,Right,tagmon,right
# === Layout ===
# Cycle Layout - Gaps, Floating, Tiling
bind=SUPER+ALT,j,switch_layout
bind=SUPER+SHIFT,equal,incgaps,1
bind=SUPER+SHIFT,minus,incgaps,-1
# === Tags (1-9): view tag ===
bind=SUPER,1,view,1
bind=SUPER,2,view,2
bind=SUPER,3,view,3
bind=SUPER,4,view,4
bind=SUPER,5,view,5
bind=SUPER,6,view,6
bind=SUPER,7,view,7
bind=SUPER,8,view,8
bind=SUPER,9,view,9
# === Tags (1-9): move focused window to tag ===
bind=SUPER+SHIFT,1,tag,1
bind=SUPER+SHIFT,2,tag,2
bind=SUPER+SHIFT,3,tag,3
bind=SUPER+SHIFT,4,tag,4
bind=SUPER+SHIFT,5,tag,5
bind=SUPER+SHIFT,6,tag,6
bind=SUPER+SHIFT,7,tag,7
bind=SUPER+SHIFT,8,tag,8
bind=SUPER+SHIFT,9,tag,9
# === Touchpad Gestures ===
# 3-finger horizontal swipe: switch between occupied workspaces
gesturebind=none,right,3,viewtoleft_have_client
gesturebind=none,left,3,viewtoright_have_client
# 4-finger vertical swipe: toggle the overview
gesturebind=none,up,4,toggleoverview
gesturebind=none,down,4,toggleoverview
@@ -0,0 +1,6 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff
focuscolor = 0x8ab4f8ff
urgentcolor = 0xff5555ff
@@ -0,0 +1,8 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12
gappih=5
gappiv=5
gappoh=5
gappov=5
borderpx=2
+18
View File
@@ -0,0 +1,18 @@
# DankMaterialShell — MangoWM configuration (managed by `dms setup`)
# Keybinds, colors, layout, outputs, cursor and window rules are pulled from the
# ./dms fragments below. Add your own binds/rules here; they sit alongside DMS's.
env=XDG_CURRENT_DESKTOP,mango
env=XDG_SESSION_TYPE,wayland
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload.
exec-once=dms run
source=./dms/colors.conf
source=./dms/layout.conf
source=./dms/cursor.conf
source=./dms/outputs.conf
source=./dms/windowrules.conf
source=./dms/binds.conf
+1 -1
View File
@@ -1,6 +1,6 @@
binds { binds {
// === System & Overview === // === System & Overview ===
Mod+D repeat=false { toggle-overview; } Mod+O repeat=false { toggle-overview; }
Mod+Tab repeat=false { toggle-overview; } Mod+Tab repeat=false { toggle-overview; }
Mod+Shift+Slash { show-hotkey-overlay; } Mod+Shift+Slash { show-hotkey-overlay; }
+34 -11
View File
@@ -138,11 +138,9 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin
return "", "", nil return "", "", nil
} }
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf // CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which // top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55 // when hyprland.lua also exists as the live config.
// produced when launched without -c. If only hyprland.conf exists, the user
// has not migrated and we must leave their config alone.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) { func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return return
@@ -156,19 +154,44 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if _, err := os.Stat(luaPath); err != nil { if _, err := os.Stat(luaPath); err != nil {
return return
} }
var strayPaths []string
confPath := filepath.Join(configDir, "hyprland.conf") confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil { if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, confPath)
}
dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
if err == nil {
for _, p := range dmsConfPaths {
if info, err := os.Lstat(p); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, p)
}
}
}
if len(strayPaths) == 0 {
return return
} }
ts := time.Now().Format("2006-01-02_15-04-05") ts := time.Now().Format("2006-01-02_15-04-05")
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf") moved := 0
if err := moveHyprlandConfigFile(confPath, dst); err != nil { for _, src := range strayPaths {
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
if logFn != nil { if logFn != nil {
logFn("Could not move stray hyprland.conf: %v", err) logFn("Could not move stray Hyprland conf file %s: %v", src, err)
} }
return continue
} }
moved++
if logFn != nil { if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst) logFn("Moved stray Hyprland conf file to %s", dst)
}
}
if moved > 0 && logFn != nil {
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
} }
} }
+15
View File
@@ -0,0 +1,15 @@
package config
import _ "embed"
//go:embed embedded/mango.conf
var MangoConfig string
//go:embed embedded/mango-colors.conf
var MangoColorsConfig string
//go:embed embedded/mango-layout.conf
var MangoLayoutConfig string
//go:embed embedded/mango-binds.conf
var MangoBindsConfig string
+1
View File
@@ -35,6 +35,7 @@ type WindowManager int
const ( const (
WindowManagerHyprland WindowManager = iota WindowManagerHyprland WindowManager = iota
WindowManagerNiri WindowManagerNiri
WindowManagerMango
) )
type Terminal int type Terminal int
+21 -1
View File
@@ -112,6 +112,11 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectXwaylandSatellite()) dependencies = append(dependencies, a.detectXwaylandSatellite())
} }
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
dependencies = append(dependencies, a.detectMatugen()) dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop()) dependencies = append(dependencies, a.detectDgop())
@@ -172,6 +177,11 @@ func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
return exec.Command("pacman", "-Si", pkg).Run() == nil return exec.Command("pacman", "-Si", pkg).Run() == nil
} }
// isSonameProvides reports whether dep is a shared-library soname
func isSonameProvides(dep string) bool {
return strings.HasSuffix(dep, ".so") || strings.Contains(dep, ".so.")
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
} }
@@ -199,6 +209,9 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"]) packages["niri"] = a.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = a.getMangoMapping(variants["mango"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
} }
return packages return packages
@@ -222,6 +235,13 @@ func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMa
return PackageMapping{Name: "niri", Repository: RepoTypeSystem} return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
} }
func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
if runtime.GOARCH == "arm64" { if runtime.GOARCH == "arm64" {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR} return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
@@ -724,7 +744,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
continue continue
} }
seen[dep] = true seen[dep] = true
if a.isInSystemRepo(dep) { if isSonameProvides(dep) || a.isInSystemRepo(dep) {
systemPkgs = append(systemPkgs, dep) systemPkgs = append(systemPkgs, dep)
} else { } else {
aurPkgs = append(aurPkgs, dep) aurPkgs = append(aurPkgs, dep)
+30
View File
@@ -337,6 +337,36 @@ func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Depen
Variant: variant, Variant: variant,
CanToggle: true, CanToggle: true,
} }
case deps.WindowManagerMango:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("mango") {
status = deps.StatusInstalled
cmd := exec.Command("mango", "-v")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "mango",
Status: status,
Version: version,
Description: "dwl-based dynamic tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
default: default:
return deps.Dependency{ return deps.Dependency{
Name: "unknown-wm", Name: "unknown-wm",
+55 -2
View File
@@ -77,7 +77,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
// Common detections using base methods // Common detections using base methods
dependencies = append(dependencies, f.detectGit()) dependencies = append(dependencies, f.detectGit())
dependencies = append(dependencies, f.detectWindowManager(wm)) wmDep := f.detectWindowManager(wm)
if wm == deps.WindowManagerMango {
wmDep.Description = "MangoWM (Wayland compositor) — the Terra repo will be enabled automatically to install it"
}
dependencies = append(dependencies, wmDep)
dependencies = append(dependencies, f.detectQuickshell()) dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectDMSGreeter()) dependencies = append(dependencies, f.detectDMSGreeter())
dependencies = append(dependencies, f.detectXDGPortal()) dependencies = append(dependencies, f.detectXDGPortal())
@@ -93,6 +97,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectXwaylandSatellite()) dependencies = append(dependencies, f.detectXwaylandSatellite())
} }
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
dependencies = append(dependencies, f.detectMatugen()) dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop()) dependencies = append(dependencies, f.detectDgop())
@@ -139,6 +148,10 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"]) packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
// mangowm resolves via Terra, enabled automatically by enableTerraRepo.
packages["mango"] = PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
} }
return packages return packages
@@ -159,7 +172,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
} }
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"} return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"}
} }
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -297,6 +310,22 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
} }
} }
// Phase 2b: Enable Terra repo for MangoWM (not in Fedora's repos). Must run
// before the DNF phase so `mangowm` resolves.
if wm == deps.WindowManagerMango {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Enabling Terra repository for MangoWM...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up the Terra repo (fyralabs) to provide mango",
}
if err := f.enableTerraRepo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
}
// Phase 3: System Packages (DNF) // Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 { if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
@@ -423,6 +452,30 @@ func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []st
return names return names
} }
// enableTerraRepo registers the persistent Terra repo (via terra-release) so
// `mangowm` resolves in the DNF phase. $releasever is single-quoted so dnf, not
// the shell, expands it.
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
// Skip if Terra is already configured
if exec.CommandContext(ctx, "sh", "-c",
"rpm -q terra-release >/dev/null 2>&1 || test -f /etc/yum.repos.d/terra.repo").Run() == nil {
f.log("Terra repository already configured, skipping enable")
return nil
}
f.log("Enabling Terra repository (fyralabs) for mango...")
cmd := privesc.ExecCommand(ctx, sudoPassword,
`dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release 2>&1`)
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to enable Terra repository", err)
f.log(fmt.Sprintf("Terra enable output: %s", string(output)))
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
f.log(fmt.Sprintf("Terra repository enabled: %s", string(output)))
return nil
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool) enabledRepos := make(map[string]bool)
+13
View File
@@ -106,6 +106,11 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectXwaylandSatellite()) dependencies = append(dependencies, g.detectXwaylandSatellite())
} }
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen()) dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop()) dependencies = append(dependencies, g.detectDgop())
@@ -176,6 +181,10 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"]) packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword} packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
case deps.WindowManagerMango:
packages["mango"] = g.getMangoMapping(variants["mango"])
packages["scenefx"] = PackageMapping{Name: "gui-libs/scenefx", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
} }
return packages return packages
@@ -197,6 +206,10 @@ func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMappin
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()} return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
} }
func (g *GentooDistribution) getMangoMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/mangowm", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string { func (g *GentooDistribution) getPrerequisites() []string {
return []string{ return []string{
"app-eselect/eselect-repository", "app-eselect/eselect-repository",
+436 -21
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"os/user"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -191,6 +192,421 @@ func upsertDefaultSession(configContent, greeterUser, command string) string {
return strings.Join(out, "\n") return strings.Join(out, "\n")
} }
func removeTomlSection(configContent, sectionName string) string {
lines := strings.Split(configContent, "\n")
var out []string
inSection := false
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
inSection = section == sectionName
if inSection {
continue
}
out = append(out, line)
continue
}
if inSection {
continue
}
out = append(out, line)
}
result := strings.TrimRight(strings.Join(out, "\n"), "\n")
if result != "" {
result += "\n"
}
return result
}
func stripDesktopExecCodes(execLine string) string {
fields := strings.Fields(execLine)
cleaned := make([]string, 0, len(fields))
for _, field := range fields {
if strings.HasPrefix(field, "%") {
continue
}
cleaned = append(cleaned, field)
}
return strings.Join(cleaned, " ")
}
func formatInitialSessionCommand(sessionExec string) string {
execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec))
if execLine == "" {
return `command = ""`
}
escaped := strings.ReplaceAll(execLine, `'`, `'\''`)
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
}
func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string {
if !enabled {
return removeTomlSection(configContent, "initial_session")
}
commandLine := formatInitialSessionCommand(sessionExec)
lines := strings.Split(configContent, "\n")
var out []string
inInitialSession := false
foundInitialSession := false
initialSessionUserSet := false
initialSessionCommandSet := false
appendInitialSessionFields := func() {
if !initialSessionUserSet {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
}
if !initialSessionCommandSet {
out = append(out, commandLine)
}
}
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
if inInitialSession {
appendInitialSessionFields()
}
inInitialSession = section == "initial_session"
if inInitialSession {
foundInitialSession = true
initialSessionUserSet = false
initialSessionCommandSet = false
}
out = append(out, line)
continue
}
if inInitialSession {
trimmed := stripTomlComment(line)
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
initialSessionUserSet = true
continue
}
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
if !initialSessionCommandSet {
out = append(out, commandLine)
initialSessionCommandSet = true
}
continue
}
}
out = append(out, line)
}
if inInitialSession {
appendInitialSessionFields()
}
if !foundInitialSession {
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
out = append(out, "")
}
out = append(out, "[initial_session]")
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
out = append(out, commandLine)
}
return strings.Join(out, "\n")
}
type greeterAutoLoginConfig struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
GreeterRememberLastUser bool `json:"greeterRememberLastUser"`
GreeterRememberLastSession bool `json:"greeterRememberLastSession"`
}
type greeterAutoLoginMemory struct {
LastSuccessfulUser string `json:"lastSuccessfulUser"`
LastSessionID string `json:"lastSessionId"`
LastSessionExec string `json:"lastSessionExec"`
AutoLoginEnabled bool `json:"autoLoginEnabled"`
}
func readGreeterAutoLoginConfig(settingsPath string) (greeterAutoLoginConfig, error) {
cfg := greeterAutoLoginConfig{
GreeterRememberLastUser: true,
GreeterRememberLastSession: true,
}
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
}
return cfg, nil
}
func readGreeterAutoLoginMemory(memoryPath string) (greeterAutoLoginMemory, error) {
var mem greeterAutoLoginMemory
data, err := os.ReadFile(memoryPath)
if err != nil {
if os.IsNotExist(err) {
return mem, nil
}
return mem, err
}
if err := json.Unmarshal(data, &mem); err != nil {
return mem, fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
return mem, nil
}
func execFromDesktopFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Exec=") {
return strings.TrimSpace(trimmed[len("Exec="):]), nil
}
}
return "", fmt.Errorf("no Exec= line found in %s", path)
}
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) {
settingsPath := filepath.Join(cacheDir, "settings.json")
if _, statErr := os.Stat(settingsPath); statErr != nil {
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
}
cfg, err := readGreeterAutoLoginConfig(settingsPath)
if err != nil {
return false, "", "", err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
mem, err := readGreeterAutoLoginMemory(memoryPath)
if err != nil {
return false, "", "", err
}
enabled = cfg.GreeterAutoLogin
if !enabled {
return false, "", "", nil
}
if !cfg.GreeterRememberLastUser || !cfg.GreeterRememberLastSession {
return true, "", "", nil
}
loginUser = mem.LastSuccessfulUser
if loginUser == "" {
current, userErr := user.Current()
if userErr != nil {
return true, "", "", userErr
}
loginUser = current.Username
}
sessionExec = mem.LastSessionExec
if sessionExec == "" && mem.LastSessionID != "" {
sessionExec, err = execFromDesktopFile(mem.LastSessionID)
if err != nil {
sessionExec = ""
}
}
return true, loginUser, sessionExec, nil
}
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup greetd config: %w", err)
}
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(content); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err)
}
if logFunc != nil && successMsg != "" {
logFunc(successMsg)
}
return nil
}
func clearGreeterAutoLoginMemory(memoryPath, sudoPassword string) error {
data, err := readGreeterMemoryFile(memoryPath, sudoPassword)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
if _, ok := raw["autoLoginEnabled"]; !ok {
return nil
}
delete(raw, "autoLoginEnabled")
encoded, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
if len(encoded) == 0 || string(encoded) == "null" {
encoded = []byte("{}")
}
encoded = append(encoded, '\n')
if err := os.WriteFile(memoryPath, encoded, 0o644); err == nil {
return nil
} else if !os.IsPermission(err) {
return err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-*.json")
if err != nil {
return fmt.Errorf("failed to create temp greeter memory file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(encoded); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greeter memory file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greeter memory file: %w", err)
}
greeterUser := DetectGreeterUser()
greeterGroup := DetectGreeterGroup()
owner := greeterUser + ":" + greeterGroup
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", greeterUser, "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); err != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); fallbackErr != nil {
return fmt.Errorf("failed to install greeter memory file (preferred %s: %w; fallback root:%s: %v)", owner, err, greeterGroup, fallbackErr)
}
}
return nil
}
func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) {
data, err := os.ReadFile(memoryPath)
if err == nil || !os.IsPermission(err) {
return data, err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-read-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file for greeter memory read: %w", err)
}
tmpPath := tmpFile.Name()
_ = tmpFile.Close()
defer os.Remove(tmpPath)
if err := privesc.Run(context.Background(), sudoPassword, "cp", "-f", memoryPath, tmpPath); err != nil {
return nil, fmt.Errorf("failed to read greeter memory at %s: %w", memoryPath, err)
}
return os.ReadFile(tmpPath)
}
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
return err
}
configPath := "/etc/greetd/config.toml"
configContent := ""
if data, readErr := os.ReadFile(configPath); readErr == nil {
configContent = string(data)
} else if !os.IsNotExist(readErr) {
return fmt.Errorf("failed to read greetd config: %w", readErr)
}
if !enabled {
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig == configContent {
if logFunc != nil {
logFunc("✓ Greeter auto-login disabled")
}
return nil
}
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
}
if loginUser == "" || sessionExec == "" {
if logFunc != nil {
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig != configContent {
return writeGreetdConfig(configPath, newConfig, nil, sudoPassword, "")
}
return nil
}
newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true)
if newConfig == configContent {
if logFunc != nil {
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
_ = clearGreeterAutoLoginMemory(memoryPath, sudoPassword)
return nil
}
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Configured greeter auto-login for %s", loginUser)); err != nil {
return err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
return nil
}
func SyncGreeterAutoLoginOnly(logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
return SyncGreetdAutoLogin(GreeterCacheDir, homeDir, logFunc, sudoPassword)
}
func DetectGreeterUser() string { func DetectGreeterUser() string {
passwdData, err := os.ReadFile("/etc/passwd") passwdData, err := os.ReadFile("/etc/passwd")
if err == nil { if err == nil {
@@ -264,6 +680,9 @@ func DetectCompositors() []string {
if utils.CommandExists("Hyprland") { if utils.CommandExists("Hyprland") {
compositors = append(compositors, "Hyprland") compositors = append(compositors, "Hyprland")
} }
if utils.CommandExists("mango") {
compositors = append(compositors, "mango")
}
return compositors return compositors
} }
@@ -572,6 +991,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
} }
runtimeDirs := []string{ runtimeDirs := []string{
filepath.Join(cacheDir, "users"),
filepath.Join(cacheDir, ".local"), filepath.Join(cacheDir, ".local"),
filepath.Join(cacheDir, ".local", "state"), filepath.Join(cacheDir, ".local", "state"),
filepath.Join(cacheDir, ".local", "share"), filepath.Join(cacheDir, ".local", "share"),
@@ -1255,6 +1675,20 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
return fmt.Errorf("greeter wallpaper override sync failed: %w", err) return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
} }
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to resolve syncing user for per-user greeter cache: %w", err)
}
if err := syncUserGreeterCacheSlot(homeDir, cacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
sudoPassword: sudoPassword,
}); err != nil {
return fmt.Errorf("per-user greeter cache sync failed: %w", err)
}
if err := SyncGreetdAutoLogin(cacheDir, homeDir, logFunc, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: greeter auto-login sync failed: %v", err))
}
if strings.ToLower(compositor) != "niri" { if strings.ToLower(compositor) != "niri" {
return nil return nil
} }
@@ -1719,29 +2153,10 @@ vt = 1
commandLine := fmt.Sprintf(`command = "%s"`, commandValue) commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine) newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)); err != nil {
if err != nil { return err
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(newConfig); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
} }
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err)
}
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue))
return nil return nil
} }
+145
View File
@@ -3,6 +3,7 @@ package greeter
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -96,3 +97,147 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
}) })
} }
} }
func TestUpsertInitialSession(t *testing.T) {
t.Parallel()
baseConfig := `[terminal]
vt = 1
[default_session]
user = "greeter"
command = "/usr/bin/dms-greeter --command niri"
`
t.Run("inserts initial session", func(t *testing.T) {
t.Parallel()
got := upsertInitialSession(baseConfig, "alice", "niri", true)
if !strings.Contains(got, "[initial_session]") {
t.Fatalf("expected [initial_session] section, got:\n%s", got)
}
if !strings.Contains(got, `user = "alice"`) {
t.Fatalf("expected alice user in initial session, got:\n%s", got)
}
if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) {
t.Fatalf("expected wrapped session command, got:\n%s", got)
}
})
t.Run("updates existing initial session", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "bob"
command = "old-command"
`
got := upsertInitialSession(existing, "alice", "Hyprland", true)
if strings.Contains(got, `user = "bob"`) {
t.Fatalf("expected bob to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `exec Hyprland`) {
t.Fatalf("expected Hyprland command, got:\n%s", got)
}
})
t.Run("removes initial session when disabled", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "alice"
command = "niri"
`
got := upsertInitialSession(existing, "", "", false)
if strings.Contains(got, "[initial_session]") {
t.Fatalf("expected initial session removed, got:\n%s", got)
}
if !strings.Contains(got, "[default_session]") {
t.Fatalf("expected default session preserved, got:\n%s", got)
}
})
}
func TestStripDesktopExecCodes(t *testing.T) {
t.Parallel()
got := stripDesktopExecCodes("niri --session %f")
want := "niri --session"
if got != want {
t.Fatalf("stripDesktopExecCodes = %q, want %q", got, want)
}
}
func TestResolveGreeterAutoLoginState(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": true,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if !enabled || loginUser != "alice" || sessionExec != "niri" {
t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": false,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if enabled || loginUser != "" || sessionExec != "" {
t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestClearGreeterAutoLoginMemory(t *testing.T) {
t.Parallel()
memoryPath := filepath.Join(t.TempDir(), "memory.json")
writeTestFile(t, memoryPath, `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice"
}`)
if err := clearGreeterAutoLoginMemory(memoryPath, ""); err != nil {
t.Fatalf("clearGreeterAutoLoginMemory returned error: %v", err)
}
data, err := os.ReadFile(memoryPath)
if err != nil {
t.Fatalf("failed to read memory file: %v", err)
}
if strings.Contains(string(data), "autoLoginEnabled") {
t.Fatalf("expected autoLoginEnabled removed, got: %s", string(data))
}
if !strings.Contains(string(data), "lastSuccessfulUser") {
t.Fatalf("expected other memory fields preserved, got: %s", string(data))
}
}
+548
View File
@@ -0,0 +1,548 @@
package greeter
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
var monitorWallpaperSanitizer = regexp.MustCompile(`[^a-zA-Z0-9]+`)
func userGreeterCacheDir(cacheDir, username string) string {
return filepath.Join(cacheDir, "users", username)
}
func isUserOwnedGreeterCacheSlot(path, username string) bool {
if strings.TrimSpace(username) == "" {
return false
}
userDir, err := filepath.Abs(userGreeterCacheDir(GreeterCacheDir, username))
if err != nil {
return false
}
abs, err := filepath.Abs(path)
if err != nil {
return false
}
return abs == userDir || strings.HasPrefix(abs, userDir+string(filepath.Separator))
}
func UserIsInGreeterGroup(username string) bool {
group := DetectGreeterGroup()
if !utils.HasGroup(group) {
return false
}
groupsCmd := exec.Command("groups", username)
groupsOutput, err := groupsCmd.Output()
if err != nil {
return false
}
return strings.Contains(string(groupsOutput), group)
}
func CanSyncOwnUserGreeterProfile(username string) bool {
currentUser, err := user.Current()
if err != nil || currentUser.Username != username {
return false
}
if !UserIsInGreeterGroup(username) {
return false
}
usersDir := filepath.Join(GreeterCacheDir, "users")
if st, err := os.Stat(usersDir); err != nil || !st.IsDir() {
return false
}
testFile := filepath.Join(usersDir, ".write-test-"+username)
file, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
if err != nil {
return false
}
_ = file.Close()
_ = os.Remove(testFile)
return true
}
func GreeterProfileSyncReady() bool {
if command := readGreeterSessionCommand(); command != "" && strings.Contains(command, "dms-greeter") {
return true
}
usersDir := filepath.Join(GreeterCacheDir, "users")
st, err := os.Stat(usersDir)
return err == nil && st.IsDir()
}
func readGreeterSessionCommand() string {
data, err := os.ReadFile("/etc/greetd/config.toml")
if err != nil {
return ""
}
inDefaultSession := false
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
inDefaultSession = strings.EqualFold(strings.Trim(trimmed, "[]"), "default_session")
continue
}
if !inDefaultSession {
continue
}
if idx := strings.Index(trimmed, "#"); idx >= 0 {
trimmed = strings.TrimSpace(trimmed[:idx])
}
if !strings.HasPrefix(trimmed, "command") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
command := strings.Trim(strings.TrimSpace(parts[1]), `"`)
if command != "" {
return command
}
}
return ""
}
// SyncUserProfileCache writes the current user's theme slot under users/<username>/
// without modifying greetd or other system configuration. Requires membership in the
// greeter group and a prior full greeter setup by an administrator.
func SyncUserProfileCache(logFunc func(string)) error {
if logFunc == nil {
logFunc = func(string) {}
}
if !GreeterProfileSyncReady() {
return fmt.Errorf("greeter is not set up on this system yet; an administrator must run 'dms greeter install' or 'dms greeter sync' once first")
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to resolve current user: %w", err)
}
if !CanSyncOwnUserGreeterProfile(currentUser.Username) {
group := DetectGreeterGroup()
return fmt.Errorf("cannot sync greeter profile: you must be in the %s group with write access to %s/users\nAsk an administrator to run:\n sudo usermod -aG %s %s\nThen log out and back in before running:\n dms greeter sync --profile",
group, GreeterCacheDir, group, currentUser.Username)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
state, err := resolveGreeterThemeSyncState(homeDir)
if err != nil {
return fmt.Errorf("failed to resolve greeter color source: %w", err)
}
if err := syncUserGreeterCacheSlot(homeDir, GreeterCacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
profileOnly: true,
}); err != nil {
return err
}
logFunc(fmt.Sprintf(" → %s/users/%s/", GreeterCacheDir, currentUser.Username))
return nil
}
func canWriteUserGreeterCacheSlot(dest, username string) bool {
return isUserOwnedGreeterCacheSlot(dest, username) && CanSyncOwnUserGreeterProfile(username)
}
type userSlotSyncOpts struct {
sudoPassword string
profileOnly bool
username string
}
func (o userSlotSyncOpts) useDirectWrite(dest string) bool {
if !o.profileOnly {
return false
}
return canWriteUserGreeterCacheSlot(dest, o.username)
}
func isGreeterCachePath(path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return true
}
cacheAbs, err := filepath.Abs(GreeterCacheDir)
if err != nil {
return true
}
if abs == cacheAbs {
return true
}
return strings.HasPrefix(abs, cacheAbs+string(filepath.Separator))
}
func greeterCacheOwner() string {
greeterGroup := DetectGreeterGroup()
daemonUser := DetectGreeterUser()
return daemonUser + ":" + greeterGroup
}
func ensureGreeterCacheSubdir(dir string, opts userSlotSyncOpts) error {
if opts.useDirectWrite(dir) {
if err := os.MkdirAll(dir, 0o770); err != nil {
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
}
return nil
}
if err := privesc.Run(context.Background(), opts.sudoPassword, "mkdir", "-p", dir); err != nil {
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
}
owner := greeterCacheOwner()
if err := privesc.Run(context.Background(), opts.sudoPassword, "chown", owner, dir); err != nil {
if fallbackErr := privesc.Run(context.Background(), opts.sudoPassword, "chown", "root:"+DetectGreeterGroup(), dir); fallbackErr != nil {
return fmt.Errorf("failed to set ownership on %s: %w", dir, err)
}
}
if err := privesc.Run(context.Background(), opts.sudoPassword, "chmod", "2770", dir); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", dir, err)
}
return nil
}
func setGreeterCacheFileOwnership(path, sudoPassword string) error {
owner := greeterCacheOwner()
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, path); err != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+DetectGreeterGroup(), path); fallbackErr != nil {
return fmt.Errorf("failed to set ownership on %s: %w", path, err)
}
}
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", path); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", path, err)
}
return nil
}
func syncUserGreeterCacheSlot(homeDir, cacheDir, username string, state greeterThemeSyncState, logFunc func(string), opts userSlotSyncOpts) error {
if strings.TrimSpace(username) == "" {
return nil
}
opts.username = username
userDir := userGreeterCacheDir(cacheDir, username)
if err := ensureGreeterCacheSubdir(userDir, opts); err != nil {
return err
}
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
settingsBytes, err := os.ReadFile(settingsPath)
if err != nil {
return fmt.Errorf("failed to read settings for user cache slot: %w", err)
}
settingsMap := map[string]any{}
if strings.TrimSpace(string(settingsBytes)) != "" {
if err := json.Unmarshal(settingsBytes, &settingsMap); err != nil {
return fmt.Errorf("failed to parse settings for user cache slot: %w", err)
}
}
if customTheme, ok := settingsMap["customThemeFile"].(string); ok && strings.TrimSpace(customTheme) != "" {
resolvedTheme := customTheme
if !filepath.IsAbs(resolvedTheme) {
resolvedTheme = filepath.Join(homeDir, resolvedTheme)
}
if st, statErr := os.Stat(resolvedTheme); statErr == nil && !st.IsDir() {
destTheme := filepath.Join(userDir, "custom-theme.json")
if err := copyFileWithPrivesc(resolvedTheme, destTheme, opts); err != nil {
return err
}
settingsMap["customThemeFile"] = destTheme
}
}
settingsBytes, err = json.Marshal(settingsMap)
if err != nil {
return fmt.Errorf("failed to marshal settings for user cache slot: %w", err)
}
if err := writeFileWithPrivesc(filepath.Join(userDir, "settings.json"), settingsBytes, opts); err != nil {
return err
}
sessionPath := filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json")
sessionBytes, err := os.ReadFile(sessionPath)
if err != nil {
return fmt.Errorf("failed to read session for user cache slot: %w", err)
}
sessionMap := map[string]any{}
if strings.TrimSpace(string(sessionBytes)) != "" {
if err := json.Unmarshal(sessionBytes, &sessionMap); err != nil {
return fmt.Errorf("failed to parse session for user cache slot: %w", err)
}
}
if err := localizeSessionWallpapers(sessionMap, userDir, opts); err != nil {
return err
}
sessionBytes, err = json.Marshal(sessionMap)
if err != nil {
return fmt.Errorf("failed to marshal session for user cache slot: %w", err)
}
if err := writeFileWithPrivesc(filepath.Join(userDir, "session.json"), sessionBytes, opts); err != nil {
return err
}
colorsSource := state.effectiveColorsSource(homeDir)
if err := copyFileWithPrivesc(colorsSource, filepath.Join(userDir, "colors.json"), opts); err != nil {
return fmt.Errorf("failed to copy colors for user cache slot: %w", err)
}
if err := syncUserProfileImage(homeDir, userDir, opts); err != nil {
return err
}
rootOverride := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
userOverride := filepath.Join(userDir, "greeter_wallpaper_override.jpg")
if st, statErr := os.Stat(rootOverride); statErr == nil && !st.IsDir() {
if err := copyFileWithPrivesc(rootOverride, userOverride, opts); err != nil {
return fmt.Errorf("failed to copy greeter wallpaper override for user cache slot: %w", err)
}
} else if opts.useDirectWrite(userOverride) {
_ = os.Remove(userOverride)
} else {
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", userOverride)
}
logFunc(fmt.Sprintf("✓ Synced per-user greeter cache for %s", username))
return nil
}
func localizeSessionWallpapers(session map[string]any, userDir string, opts userSlotSyncOpts) error {
stringKeys := []struct {
key string
prefix string
}{
{"wallpaperPath", "wallpaper"},
{"wallpaperPathLight", "wallpaper-light"},
{"wallpaperPathDark", "wallpaper-dark"},
}
for _, item := range stringKeys {
if err := localizeWallpaperStringField(session, item.key, userDir, item.prefix, opts); err != nil {
return err
}
}
mapKeys := []struct {
key string
prefix string
}{
{"monitorWallpapers", "wallpaper-monitor"},
{"monitorWallpapersLight", "wallpaper-monitor-light"},
{"monitorWallpapersDark", "wallpaper-monitor-dark"},
}
for _, item := range mapKeys {
if err := localizeWallpaperMapField(session, item.key, userDir, item.prefix, opts); err != nil {
return err
}
}
return nil
}
func localizeWallpaperStringField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
raw, ok := session[key]
if !ok {
return nil
}
path, ok := raw.(string)
if !ok || strings.TrimSpace(path) == "" {
return nil
}
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix, opts)
if err != nil {
return err
}
if dest != "" {
session[key] = dest
}
return nil
}
func localizeWallpaperMapField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
raw, ok := session[key]
if !ok || raw == nil {
return nil
}
values, ok := raw.(map[string]any)
if !ok {
return nil
}
for monitor, rawPath := range values {
path, ok := rawPath.(string)
if !ok || strings.TrimSpace(path) == "" {
continue
}
safeMonitor := monitorWallpaperSanitizer.ReplaceAllString(monitor, "-")
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix+"-"+safeMonitor, opts)
if err != nil {
return err
}
if dest != "" {
values[monitor] = dest
}
}
return nil
}
func copyWallpaperIntoUserCache(srcPath, userDir, prefix string, opts userSlotSyncOpts) (string, error) {
if strings.TrimSpace(srcPath) == "" {
return "", nil
}
st, err := os.Stat(srcPath)
if err != nil || st.IsDir() {
return "", nil
}
ext := filepath.Ext(srcPath)
if ext == "" {
ext = ".jpg"
}
dest := filepath.Join(userDir, prefix+ext)
if err := copyFileWithPrivesc(srcPath, dest, opts); err != nil {
return "", err
}
return dest, nil
}
func copyFileWithPrivesc(src, dest string, opts userSlotSyncOpts) error {
if opts.useDirectWrite(dest) {
if err := os.MkdirAll(filepath.Dir(dest), 0o770); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
}
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("failed to read %s: %w", src, err)
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", dest, err)
}
return nil
}
if !isGreeterCachePath(dest) {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
}
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("failed to read %s: %w", src, err)
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", dest, err)
}
return nil
}
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", dest)
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", src, dest); err != nil {
return fmt.Errorf("failed to copy %s to %s: %w", src, dest, err)
}
return setGreeterCacheFileOwnership(dest, opts.sudoPassword)
}
func writeFileWithPrivesc(path string, data []byte, opts userSlotSyncOpts) error {
if opts.useDirectWrite(path) {
if err := os.MkdirAll(filepath.Dir(path), 0o770); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", path, err)
}
return nil
}
if !isGreeterCachePath(path) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", path, err)
}
return nil
}
tmp, err := os.CreateTemp("", "dms-greeter-user-cache-*")
if err != nil {
return fmt.Errorf("failed to create temp file for %s: %w", path, err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to write temp file for %s: %w", path, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file for %s: %w", path, err)
}
defer os.Remove(tmpPath)
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", tmpPath, path); err != nil {
return fmt.Errorf("failed to install %s: %w", path, err)
}
return setGreeterCacheFileOwnership(path, opts.sudoPassword)
}
func resolveUserProfileImageSource(homeDir string) string {
candidates := []string{
filepath.Join(homeDir, ".face"),
filepath.Join(homeDir, ".face.icon"),
}
if homeDir != "" {
username := filepath.Base(homeDir)
if username != "" && username != "." && username != string(filepath.Separator) {
candidates = append([]string{filepath.Join("/var/lib/AccountsService/icons", username)}, candidates...)
}
}
for _, src := range candidates {
st, err := os.Stat(src)
if err == nil && !st.IsDir() && st.Size() > 0 {
return src
}
}
return ""
}
func syncUserProfileImage(homeDir, userDir string, opts userSlotSyncOpts) error {
for _, name := range []string{"profile.jpg", "profile.jpeg", "profile.png", "profile.webp"} {
path := filepath.Join(userDir, name)
if opts.useDirectWrite(path) {
_ = os.Remove(path)
} else {
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
}
}
src := resolveUserProfileImageSource(homeDir)
if src == "" {
return nil
}
ext := filepath.Ext(src)
if ext == "" {
ext = ".jpg"
}
dest := filepath.Join(userDir, "profile"+ext)
if err := copyFileWithPrivesc(src, dest, opts); err != nil {
return fmt.Errorf("failed to copy profile image for user cache slot: %w", err)
}
return nil
}
@@ -0,0 +1,81 @@
package greeter
import (
"path/filepath"
"testing"
)
func TestUserGreeterCacheDir(t *testing.T) {
t.Parallel()
got := userGreeterCacheDir("/var/cache/dms-greeter", "alice")
want := filepath.Join("/var/cache/dms-greeter", "users", "alice")
if got != want {
t.Fatalf("userGreeterCacheDir() = %q, want %q", got, want)
}
}
func TestResolveUserProfileImageSource(t *testing.T) {
t.Parallel()
homeDir := t.TempDir()
facePath := filepath.Join(homeDir, ".face")
writeTestFile(t, facePath, "face")
got := resolveUserProfileImageSource(homeDir)
if got != facePath {
t.Fatalf("resolveUserProfileImageSource() = %q, want %q", got, facePath)
}
}
func TestIsUserOwnedGreeterCacheSlot(t *testing.T) {
t.Parallel()
slot := filepath.Join(GreeterCacheDir, "users", "alice", "settings.json")
if !isUserOwnedGreeterCacheSlot(slot, "alice") {
t.Fatalf("expected alice to own %q", slot)
}
if isUserOwnedGreeterCacheSlot(slot, "bob") {
t.Fatalf("expected bob not to own alice slot")
}
if isUserOwnedGreeterCacheSlot(filepath.Join(GreeterCacheDir, "settings.json"), "alice") {
t.Fatalf("expected root cache file not to be a user slot")
}
}
func TestLocalizeSessionWallpapers(t *testing.T) {
t.Parallel()
homeDir := t.TempDir()
userDir := filepath.Join(homeDir, "users", "alice")
wallpaperPath := filepath.Join(homeDir, "wall.jpg")
writeTestFile(t, wallpaperPath, "wallpaper")
session := map[string]any{
"wallpaperPath": wallpaperPath,
"monitorWallpapers": map[string]any{
"DP-1": wallpaperPath,
},
}
if err := localizeSessionWallpapers(session, userDir, userSlotSyncOpts{}); err != nil {
t.Fatalf("localizeSessionWallpapers returned error: %v", err)
}
gotPath, ok := session["wallpaperPath"].(string)
if !ok || gotPath == "" {
t.Fatalf("expected localized wallpaperPath, got %#v", session["wallpaperPath"])
}
if gotPath == wallpaperPath {
t.Fatalf("expected copied wallpaper path, still points to source")
}
monitorMap, ok := session["monitorWallpapers"].(map[string]any)
if !ok {
t.Fatalf("expected monitorWallpapers map")
}
monitorPath, ok := monitorMap["DP-1"].(string)
if !ok || monitorPath == "" || monitorPath == wallpaperPath {
t.Fatalf("expected localized monitor wallpaper, got %#v", monitorMap["DP-1"])
}
}
+3 -1
View File
@@ -364,8 +364,10 @@ func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
return deps.WindowManagerNiri, nil return deps.WindowManagerNiri, nil
case "hyprland": case "hyprland":
return deps.WindowManagerHyprland, nil return deps.WindowManagerHyprland, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
default: default:
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor) return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri', 'hyprland', or 'mango'", r.cfg.Compositor)
} }
} }
+683 -28
View File
@@ -68,6 +68,8 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
Effective: result.DMSStatus.Effective, Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy, OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage, StatusMessage: result.DMSStatus.StatusMessage,
ConfigFormat: result.DMSStatus.ConfigFormat,
ReadOnly: result.DMSStatus.ReadOnly,
} }
} }
@@ -219,6 +221,9 @@ func (h *HyprlandProvider) validateAction(action string) error {
} }
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
if err := h.validateAction(action); err != nil { if err := h.validateAction(action); err != nil {
return err return err
} }
@@ -242,9 +247,10 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
} }
} }
normalizedKey := strings.ToLower(key) canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{ existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: key, Key: canonicalKey,
Action: action, Action: action,
Description: description, Description: description,
Flags: flags, Flags: flags,
@@ -255,21 +261,28 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
} }
func (h *HyprlandProvider) RemoveBind(key string) error { func (h *HyprlandProvider) RemoveBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds() existingBinds, err := h.loadOverrideBinds()
if err != nil { if err != nil {
return nil return nil
} }
normalizedKey := strings.ToLower(key) canonicalKey := canonicalHyprlandOverrideKey(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true} normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, Unbind: true}
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
} }
func (h *HyprlandProvider) ResetBind(key string) error { func (h *HyprlandProvider) ResetBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds() existingBinds, err := h.loadOverrideBinds()
if err != nil { if err != nil {
return nil return nil
} }
normalizedKey := strings.ToLower(key) normalizedKey := hyprlandOverrideMapKey(key)
delete(existingBinds, normalizedKey) delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
} }
@@ -284,10 +297,46 @@ type hyprlandOverrideBind struct {
Unbind bool Unbind bool
} }
func (h *HyprlandProvider) ensureWritableConfig() error {
if h.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds")
}
return nil
}
func (h *HyprlandProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
expanded = h.configPath
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
return readLuaOrHyprlangOverride(h.GetOverridePath()) return readLuaOrHyprlangOverride(h.GetOverridePath())
} }
func canonicalHyprlandOverrideKey(key string) string {
trimmed := strings.TrimSpace(key)
normalized := luaKeyComboToInternalKey(trimmed)
if normalized == "" {
return trimmed
}
return normalized
}
func hyprlandOverrideMapKey(key string) string {
return strings.ToLower(canonicalHyprlandOverrideKey(key))
}
func (h *HyprlandProvider) getBindSortPriority(action string) int { func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch { switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
@@ -368,24 +417,629 @@ func normalizeLuaBindKeyPart(part string) string {
return part return part
} }
type luaField struct {
name string
value string
}
func luaDispatcherTableCall(funcName string, fields ...luaField) string {
parts := make([]string, 0, len(fields))
for _, field := range fields {
if field.name == "" || field.value == "" {
continue
}
parts = append(parts, field.name+" = "+field.value)
}
return fmt.Sprintf(`%s({ %s })`, funcName, strings.Join(parts, ", "))
}
func luaStringField(name, value string) luaField {
return luaField{name: name, value: strconv.Quote(strings.TrimSpace(value))}
}
func luaBoolField(name string, value bool) luaField {
if value {
return luaField{name: name, value: "true"}
}
return luaField{name: name, value: "false"}
}
func luaNumberOrStringField(name, value string) luaField {
value = strings.TrimSpace(value)
if isBareLuaNumber(value) {
return luaField{name: name, value: value}
}
return luaStringField(name, value)
}
func isBareLuaNumber(value string) bool {
if value == "" || strings.HasPrefix(value, "+") {
return false
}
if value[0] == '-' {
value = value[1:]
}
if value == "" {
return false
}
digitsBeforeDot := 0
i := 0
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsBeforeDot++
i++
}
digitsAfterDot := 0
if i < len(value) && value[i] == '.' {
i++
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsAfterDot++
i++
}
}
return i == len(value) && (digitsBeforeDot > 0 || digitsAfterDot > 0)
}
func splitHyprlandAction(action string) (dispatcher, params string) {
action = strings.TrimSpace(action)
if action == "" {
return "", ""
}
idx := strings.IndexFunc(action, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
})
if idx < 0 {
return strings.ToLower(action), ""
}
return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:])
}
func isKnownHyprlandDispatcher(dispatcher string) bool {
switch dispatcher {
case "exec", "execr", "spawn",
"killactive", "forcekillactive", "closewindow", "killwindow",
"signal", "signalwindow", "togglefloating", "setfloating", "settiled",
"workspace", "renameworkspace", "fullscreen", "fullscreenstate", "fakefullscreen",
"movetoworkspace", "movetoworkspacesilent", "pseudo", "movefocus",
"movewindow", "swapwindow", "centerwindow", "togglegroup", "changegroupactive",
"movegroupwindow", "focusmonitor", "movecursortocorner", "movecursor",
"workspaceopt", "exit", "movecurrentworkspacetomonitor", "focusworkspaceoncurrentmonitor",
"moveworkspacetomonitor", "togglespecialworkspace", "forcerendererreload",
"resizeactive", "moveactive", "cyclenext", "focuswindowbyclass", "focuswindow",
"tagwindow", "toggleswallow", "submap", "pass", "sendshortcut", "sendkeystate",
"layoutmsg", "splitratio", "dpms", "movewindowpixel", "resizewindowpixel",
"swapnext", "swapactiveworkspaces", "pin", "mouse", "bringactivetotop",
"alterzorder", "focusurgentorlast", "focuscurrentorlast", "lockgroups",
"lockactivegroup", "moveintogroup", "moveoutofgroup", "movewindoworgroup",
"moveintoorcreategroup", "setignoregrouplock", "denywindowfromgroup", "event",
"global", "setprop", "forceidle":
return true
default:
return false
}
}
func firstParam(params string) (head, rest string) {
params = strings.TrimSpace(params)
if params == "" {
return "", ""
}
fields := strings.Fields(params)
if len(fields) == 0 {
return "", ""
}
head = fields[0]
rest = strings.TrimSpace(strings.TrimPrefix(params, head))
return head, rest
}
func xyParams(params string) (x, y string, relative bool, ok bool) {
fields := strings.Fields(params)
if len(fields) > 0 && strings.EqualFold(fields[0], "exact") {
relative = false
fields = fields[1:]
} else {
relative = true
}
if len(fields) < 2 {
return "", "", relative, false
}
return fields[0], fields[1], relative, true
}
func dispatcherWorkspaceMove(params string, follow *bool) string {
workspace, window := firstParam(params)
if workspace == "" {
return ""
}
fields := []luaField{luaStringField("workspace", workspace)}
if follow != nil {
fields = append(fields, luaBoolField("follow", *follow))
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.move", fields...)
}
func dispatcherActiveMoveResize(funcName, params string) string {
x, y, relative, ok := xyParams(params)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall(funcName,
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
)
}
func dispatcherWindowMoveResize(funcName, params string) string {
geometry, window := splitCommaParams(params)
x, y, relative, ok := xyParams(geometry)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
fields := []luaField{
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall(funcName, fields...)
}
func splitCommaParams(params string) (left, right string) {
left = strings.TrimSpace(params)
if idx := strings.Index(left, ","); idx >= 0 {
right = strings.TrimSpace(left[idx+1:])
left = strings.TrimSpace(left[:idx])
}
return left, right
}
func luaHyprctlDispatchFunction(action string) string {
return fmt.Sprintf(`function() hl.exec_cmd(%s) end`, strconv.Quote("hyprctl dispatch "+strings.TrimSpace(action)))
}
func luaToggleActionValue(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func dispatcherToggleTableCall(funcName, params string) string {
return luaDispatcherTableCall(funcName, luaStringField("action", luaToggleActionValue(params)))
}
func dispatcherCycleNext(params string) string {
params = strings.TrimSpace(strings.ToLower(params))
if params == "" {
return `hl.dsp.window.cycle_next()`
}
fields := []luaField{}
for _, field := range strings.Fields(params) {
switch field {
case "prev", "previous", "b":
fields = append(fields, luaBoolField("next", false))
case "next", "f":
fields = append(fields, luaBoolField("next", true))
case "tiled":
fields = append(fields, luaBoolField("tiled", true))
case "floating":
fields = append(fields, luaBoolField("floating", true))
}
}
if len(fields) == 0 {
return ""
}
return luaDispatcherTableCall("hl.dsp.window.cycle_next", fields...)
}
func dispatcherSwapNext(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "prev", "previous", "b":
return `hl.dsp.window.swap({ prev = true })`
default:
return `hl.dsp.window.swap({ next = true })`
}
}
func dispatcherGroupActive(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "f", "next", "forward":
return `hl.dsp.group.next()`
case "b", "prev", "previous", "backward":
return `hl.dsp.group.prev()`
}
if isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.group.active", luaNumberOrStringField("index", params))
}
return ""
}
func dispatcherMoveGroupWindow(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "b", "prev", "previous", "backward":
return `hl.dsp.group.move_window({ forward = false })`
default:
return `hl.dsp.group.move_window({ forward = true })`
}
}
func dispatcherCursorMove(params string) string {
x, y, _, ok := xyParams(params)
if !ok || !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall("hl.dsp.cursor.move", luaNumberOrStringField("x", x), luaNumberOrStringField("y", y))
}
func dispatcherSignal(params string) string {
signal, window := firstParam(params)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherSignalWindow(params string) string {
window, rest := firstParam(params)
signal, _ := firstParam(rest)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherTagWindow(params string) string {
tag, window := firstParam(params)
if tag == "" {
return ""
}
fields := []luaField{luaStringField("tag", tag)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.tag", fields...)
}
func luaActionStringFromKnownHyprlandAction(action string) (string, bool) {
dispatcher, params := splitHyprlandAction(action)
switch dispatcher {
case "spawn", "exec":
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true
case "execr":
return fmt.Sprintf(`hl.dsp.exec_raw(%s)`, strconv.Quote(params)), true
case "killactive":
return `hl.dsp.window.close()`, true
case "forcekillactive":
return `hl.dsp.window.kill()`, true
case "closewindow":
if params == "" {
return `hl.dsp.window.close()`, true
}
return luaDispatcherTableCall("hl.dsp.window.close", luaStringField("window", params)), true
case "killwindow":
if params == "" {
return `hl.dsp.window.kill()`, true
}
return luaDispatcherTableCall("hl.dsp.window.kill", luaStringField("window", params)), true
case "togglefloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "toggle"), true
case "setfloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "on"), true
case "settiled":
return dispatcherToggleTableCall("hl.dsp.window.float", "off"), true
case "fullscreen":
mode := strings.TrimSpace(params)
switch mode {
case "", "0":
return `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`, true
case "1":
return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true
}
return luaHyprctlDispatchFunction(action), true
case "fullscreenstate":
internal, rest := firstParam(params)
client, _ := firstParam(rest)
if internal != "" && client != "" {
return luaDispatcherTableCall("hl.dsp.window.fullscreen_state",
luaNumberOrStringField("internal", internal),
luaNumberOrStringField("client", client),
), true
}
case "fakefullscreen":
return luaHyprctlDispatchFunction(action), true
case "pin":
if params == "" {
return `hl.dsp.window.pin()`, true
}
return dispatcherToggleTableCall("hl.dsp.window.pin", params), true
case "pseudo":
return dispatcherToggleTableCall("hl.dsp.window.pseudo", params), true
case "centerwindow":
return `hl.dsp.window.center()`, true
case "resizewindow":
return `hl.dsp.window.resize()`, true
case "movewindow":
if params == "" {
return `hl.dsp.window.drag()`, true
}
if monitor, ok := strings.CutPrefix(params, "mon:"); ok {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("monitor", monitor)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params)), true
case "swapwindow":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.window.swap", luaStringField("direction", params)), true
case "swapnext":
return dispatcherSwapNext(params), true
case "resizeactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "moveactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "resizewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "workspace":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params)), true
case "focusworkspaceoncurrentmonitor":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params), luaBoolField("on_current_monitor", true)), true
case "movetoworkspace":
if expr := dispatcherWorkspaceMove(params, nil); expr != "" {
return expr, true
}
case "movetoworkspacesilent":
follow := false
if expr := dispatcherWorkspaceMove(params, &follow); expr != "" {
return expr, true
}
case "togglespecialworkspace":
if params == "" {
return `hl.dsp.workspace.toggle_special()`, true
}
return fmt.Sprintf(`hl.dsp.workspace.toggle_special(%s)`, strconv.Quote(params)), true
case "renameworkspace":
workspace, name := firstParam(params)
if workspace != "" {
fields := []luaField{luaStringField("workspace", workspace)}
if name != "" {
fields = append(fields, luaStringField("name", name))
}
return luaDispatcherTableCall("hl.dsp.workspace.rename", fields...), true
}
case "movecurrentworkspacetomonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("monitor", params)), true
}
case "moveworkspacetomonitor":
workspace, monitor := firstParam(params)
if workspace != "" && monitor != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true
}
case "workspaceopt":
return luaHyprctlDispatchFunction(action), true
case "swapactiveworkspaces":
monitor1, rest := firstParam(params)
monitor2, _ := firstParam(rest)
if monitor1 != "" && monitor2 != "" {
return luaDispatcherTableCall("hl.dsp.workspace.swap_monitors", luaStringField("monitor1", monitor1), luaStringField("monitor2", monitor2)), true
}
case "movefocus":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("direction", params)), true
}
case "focusmonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("monitor", params)), true
}
case "focuswindow":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true
}
case "focuswindowbyclass":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", "class:"+params)), true
}
case "focuscurrentorlast":
return `hl.dsp.focus({ last = true })`, true
case "focusurgentorlast":
return `hl.dsp.focus({ urgent_or_last = true })`, true
case "cyclenext":
if expr := dispatcherCycleNext(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "layoutmsg":
if params != "" {
return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true
}
case "splitratio":
return luaHyprctlDispatchFunction(action), true
case "alterzorder":
mode, window := firstParam(params)
if mode != "" {
fields := []luaField{luaStringField("mode", mode)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.alter_zorder", fields...), true
}
case "setprop":
window, rest := firstParam(params)
prop, value := firstParam(rest)
if window != "" && prop != "" && value != "" {
return luaDispatcherTableCall("hl.dsp.window.set_prop",
luaStringField("window", window),
luaStringField("prop", prop),
luaStringField("value", value),
), true
}
case "bringactivetotop":
return `hl.dsp.window.bring_to_top()`, true
case "toggleswallow":
return `hl.dsp.window.toggle_swallow()`, true
case "signal":
if expr := dispatcherSignal(params); expr != "" {
return expr, true
}
case "signalwindow":
if expr := dispatcherSignalWindow(params); expr != "" {
return expr, true
}
case "tagwindow":
if expr := dispatcherTagWindow(params); expr != "" {
return expr, true
}
case "dpms":
dpmsAction := strings.TrimSpace(params)
switch dpmsAction {
case "on":
dpmsAction = "enable"
case "off":
dpmsAction = "disable"
}
if dpmsAction == "" {
return `hl.dsp.dpms({})`, true
}
return luaDispatcherTableCall("hl.dsp.dpms", luaStringField("action", dpmsAction)), true
case "exit":
return `hl.dsp.exit()`, true
case "submap":
return fmt.Sprintf(`hl.dsp.submap(%s)`, strconv.Quote(params)), true
case "global":
return fmt.Sprintf(`hl.dsp.global(%s)`, strconv.Quote(params)), true
case "event":
return fmt.Sprintf(`hl.dsp.event(%s)`, strconv.Quote(params)), true
case "pass":
if params == "" {
return `hl.dsp.pass({})`, true
}
return luaDispatcherTableCall("hl.dsp.pass", luaStringField("window", params)), true
case "sendshortcut":
mod, rest := firstParam(params)
key, window := firstParam(rest)
if mod != "" && key != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_shortcut", fields...), true
}
case "sendkeystate":
mod, rest := firstParam(params)
key, rest := firstParam(rest)
state, window := firstParam(rest)
if mod != "" && key != "" && state != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key), luaStringField("state", state)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_key_state", fields...), true
}
case "movecursortocorner":
if params != "" && isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.cursor.move_to_corner", luaNumberOrStringField("corner", params)), true
}
case "movecursor":
if expr := dispatcherCursorMove(params); expr != "" {
return expr, true
}
case "togglegroup":
return `hl.dsp.group.toggle()`, true
case "changegroupactive":
if expr := dispatcherGroupActive(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movegroupwindow":
return dispatcherMoveGroupWindow(params), true
case "moveintogroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_group", params)), true
}
case "moveintoorcreategroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_or_create_group", params)), true
}
case "moveoutofgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("out_of_group", params)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaBoolField("out_of_group", true)), true
case "movewindoworgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true
}
case "lockgroups":
return dispatcherToggleTableCall("hl.dsp.group.lock", params), true
case "lockactivegroup":
return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true
case "denywindowfromgroup":
return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true
case "setignoregrouplock":
return luaHyprctlDispatchFunction(action), true
case "forcerendererreload":
return `hl.dsp.force_renderer_reload()`, true
case "forceidle":
if params != "" && isBareLuaNumber(params) {
return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true
}
}
if isKnownHyprlandDispatcher(dispatcher) {
return luaHyprctlDispatchFunction(action), true
}
return "", false
}
func luaActionStringFromHyprlangAction(action string) string { func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action) action = strings.TrimSpace(action)
if strings.HasPrefix(action, "spawn ") { if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn ")))) return expr
}
if strings.HasPrefix(action, "exec ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
}
switch action {
case "killactive":
return `hl.dsp.window.kill()`
case "togglefloating":
return `hl.dsp.window.float({ action = "toggle" })`
case "exit":
return `hl.dsp.exit()`
default:
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
} }
return action
} }
func luaExprToInternalAction(expr string) string { func luaExprToInternalAction(expr string) string {
@@ -407,7 +1061,7 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
if strings.Contains(bind.Flags, "e") { if strings.Contains(bind.Flags, "e") {
opts = append(opts, "repeating = true") opts = append(opts, "repeating = true")
} }
if bind.Description != "" && strings.Contains(bind.Flags, "d") { if bind.Description != "" {
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description))) opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
} }
return opts return opts
@@ -426,13 +1080,9 @@ func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
sb.WriteByte('\n') sb.WriteByte('\n')
if len(opts) > 0 { if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", ")) fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
if bind.Description != "" {
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
} else { } else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr) fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
} }
}
sb.WriteByte('\n') sb.WriteByte('\n')
} }
@@ -450,6 +1100,9 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
action := luaExprToInternalAction(actionExpr) action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix) flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix) description := luaBindOptDescription(optSuffix)
if description == "" {
description = luaLineTrailingComment(line)
}
return &hyprlandOverrideBind{ return &hyprlandOverrideBind{
Key: internalKey, Key: internalKey,
Action: action, Action: action,
@@ -498,11 +1151,12 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
continue continue
} }
if key, ok := parseLuaUnbindLine(line); ok { if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[strings.ToLower(key)] = key pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key)
continue continue
} }
if kb, ok := parseLuaBindOverrideLine(line); ok { if kb, ok := parseLuaBindOverrideLine(line); ok {
normalizedKey := strings.ToLower(kb.Key) kb.Key = canonicalHyprlandOverrideKey(kb.Key)
normalizedKey := hyprlandOverrideMapKey(kb.Key)
binds[normalizedKey] = kb binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey) delete(pendingUnbinds, normalizedKey)
continue continue
@@ -520,7 +1174,8 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
action = kb.Dispatcher + " " + kb.Params action = kb.Dispatcher + " " + kb.Params
} }
flags := kb.Flags flags := kb.Flags
normalizedKey := strings.ToLower(keyStr) keyStr = canonicalHyprlandOverrideKey(keyStr)
normalizedKey := hyprlandOverrideMapKey(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{ binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr, Key: keyStr,
Action: action, Action: action,
@@ -54,6 +54,8 @@ type HyprlandParser struct {
dmsProcessed bool dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides) removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf} defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
configFormat string
readOnly bool
} }
func NewHyprlandParser(configDir string) *HyprlandParser { func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -310,6 +312,8 @@ type HyprlandDMSStatus struct {
Effective bool Effective bool
OverriddenBy int OverriddenBy int
StatusMessage string StatusMessage string
ConfigFormat string
ReadOnly bool
} }
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
@@ -319,6 +323,8 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
IncludePosition: p.dmsIncludePos, IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount, TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS, BindsAfterDMS: p.bindsAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
} }
switch { switch {
@@ -398,6 +404,13 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
} else {
p.configFormat = "hyprlang"
p.readOnly = true
}
section, err := p.parseFileWithSource(mainConfig, "") section, err := p.parseFileWithSource(mainConfig, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -869,23 +882,20 @@ func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool)
return "", i, false return "", i, false
} }
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses // parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping at
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings). // the next top-level comma. It handles nested calls/tables and inline functions.
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) { func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
start = skipLuaWS(line, start) start = skipLuaWS(line, start)
if start >= len(line) { if start >= len(line) {
return "", start, false return "", start, false
} }
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...) i := start
firstParen := strings.IndexByte(line[start:], '(') parenDepth := 0
if firstParen < 0 { braceDepth := 0
return "", start, false bracketDepth := 0
} functionDepth := 0
i := start + firstParen
depth := 0
inStr := byte(0) inStr := byte(0)
esc := false esc := false
exprStart := start
for ; i < len(line); i++ { for ; i < len(line); i++ {
c := line[i] c := line[i]
if inStr != 0 { if inStr != 0 {
@@ -902,19 +912,66 @@ func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok boo
} }
continue continue
} }
if c == '[' && i+1 < len(line) && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return "", start, false
}
if luaWordAt(line, i, "function") {
functionDepth++
i += len("function") - 1
continue
}
if luaWordAt(line, i, "end") && functionDepth > 0 {
functionDepth--
i += len("end") - 1
continue
}
switch c { switch c {
case '"', '\'': case '"', '\'':
inStr = c inStr = c
case '(': case '(':
depth++ parenDepth++
case ')': case ')':
depth-- if parenDepth > 0 {
if depth == 0 { parenDepth--
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true }
case '{':
braceDepth++
case '}':
if braceDepth > 0 {
braceDepth--
}
case '[':
bracketDepth++
case ']':
if bracketDepth > 0 {
bracketDepth--
}
case ',':
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && functionDepth == 0 {
return strings.TrimSpace(line[start:i]), i, true
} }
} }
} }
return "", start, false expr = strings.TrimSpace(line[start:i])
return expr, i, expr != ""
}
func luaWordAt(line string, idx int, word string) bool {
if idx < 0 || idx+len(word) > len(line) || line[idx:idx+len(word)] != word {
return false
}
before := idx == 0 || !isLuaIdentByte(line[idx-1])
afterIdx := idx + len(word)
after := afterIdx >= len(line) || !isLuaIdentByte(line[afterIdx])
return before && after
}
func isLuaIdentByte(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
} }
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line. // parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
@@ -993,19 +1050,39 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if arg != "" { if arg != "" {
if u, err := strconv.Unquote(arg); err == nil { if u, err := strconv.Unquote(arg); err == nil {
if strings.HasPrefix(u, "hyprctl dispatch ") { if strings.HasPrefix(u, "hyprctl dispatch ") {
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch ")) return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch ")))
parts := strings.SplitN(rest, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], parts[1]
} }
return "exec", u return "exec", u
} }
} }
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd")) return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
case strings.Contains(expr, "hl.dsp.window.kill()"): case strings.HasPrefix(expr, "hl.dsp.exec_raw("):
return "execr", luaCallStringArgValue(expr, "hl.dsp.exec_raw")
case strings.HasPrefix(expr, "hl.dispatch("):
if arg := luaCallStringArgValue(expr, "hl.dispatch"); arg != "" {
return splitDispatchCommand(arg)
}
return "", ""
case strings.Contains(expr, "hl.exec_cmd("):
if arg := luaEmbeddedCallStringArgValue(expr, "hl.exec_cmd"); strings.HasPrefix(arg, "hyprctl dispatch ") {
return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(arg, "hyprctl dispatch ")))
}
case strings.HasPrefix(expr, "hl.dsp.window.close("):
if window := luaTableStringField(expr, "window"); window != "" {
return "closewindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" {
return "closewindow", arg
}
return "killactive", "" return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.kill("):
if window := luaTableStringField(expr, "window"); window != "" {
return "killwindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" {
return "killwindow", arg
}
return "forcekillactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("): case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") { switch luaTableStringField(expr, "mode") {
case "maximized", "maximize": case "maximized", "maximize":
@@ -1014,10 +1091,55 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
return "fullscreen", "0" return "fullscreen", "0"
} }
return "fullscreen", luaTableStringField(expr, "mode") return "fullscreen", luaTableStringField(expr, "mode")
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen_state("):
internal := luaStringValue(luaTableScalarField(expr, "internal"))
client := luaStringValue(luaTableScalarField(expr, "client"))
return joinDispatcherParams("fullscreenstate", internal, client)
case strings.HasPrefix(expr, "hl.dsp.window.float("): case strings.HasPrefix(expr, "hl.dsp.window.float("):
switch luaToggleActionToLegacy(luaTableStringField(expr, "action")) {
case "on":
return "setfloating", ""
case "off":
return "settiled", ""
default:
return "togglefloating", "" return "togglefloating", ""
}
case strings.HasPrefix(expr, "hl.dsp.window.pseudo("):
action := luaToggleActionToLegacy(luaTableStringField(expr, "action"))
if action == "" || action == "toggle" {
return "pseudo", ""
}
return "pseudo", action
case strings.HasPrefix(expr, "hl.dsp.window.pin("):
if action := luaToggleActionToLegacy(luaTableStringField(expr, "action")); action != "" && action != "toggle" {
return "pin", action
}
return "pin", ""
case strings.Contains(expr, "hl.dsp.window.center()"):
return "centerwindow", ""
case strings.Contains(expr, "hl.dsp.window.bring_to_top()"):
return "bringactivetotop", ""
case strings.Contains(expr, "hl.dsp.window.toggle_swallow()"):
return "toggleswallow", ""
case strings.Contains(expr, "hl.dsp.group.toggle()"): case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", "" return "togglegroup", ""
case strings.Contains(expr, "hl.dsp.group.next()"):
return "changegroupactive", "f"
case strings.Contains(expr, "hl.dsp.group.prev()"):
return "changegroupactive", "b"
case strings.HasPrefix(expr, "hl.dsp.group.active("):
return "changegroupactive", luaStringValue(luaTableScalarField(expr, "index"))
case strings.HasPrefix(expr, "hl.dsp.group.move_window("):
if forward, ok := luaTableBoolField(expr, "forward"); ok && !forward {
return "movegroupwindow", "b"
}
return "movegroupwindow", "f"
case strings.HasPrefix(expr, "hl.dsp.group.lock_active("):
return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.group.lock("):
return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("):
return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.focus("): case strings.HasPrefix(expr, "hl.dsp.focus("):
switch { switch {
case luaTableStringField(expr, "direction") != "": case luaTableStringField(expr, "direction") != "":
@@ -1025,18 +1147,58 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
case luaTableStringField(expr, "monitor") != "": case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor") return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "": case luaTableStringField(expr, "workspace") != "":
if luaTableBoolFieldValue(expr, "on_current_monitor") {
return "focusworkspaceoncurrentmonitor", luaTableStringField(expr, "workspace")
}
return "workspace", luaTableStringField(expr, "workspace") return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "": case luaTableStringField(expr, "window") != "":
return "focuswindow", luaTableStringField(expr, "window") return "focuswindow", luaTableStringField(expr, "window")
case luaTableBoolFieldValue(expr, "urgent_or_last"):
return "focusurgentorlast", ""
case luaTableBoolFieldValue(expr, "last"):
return "focuscurrentorlast", ""
} }
case strings.HasPrefix(expr, "hl.dsp.window.move("): case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch { switch {
case luaTableScalarField(expr, "x") != "" || luaTableScalarField(expr, "y") != "":
x := luaStringValue(luaTableScalarField(expr, "x"))
y := luaStringValue(luaTableScalarField(expr, "y"))
if x == "" {
x = "0"
}
if y == "" {
y = "0"
}
prefix := ""
if raw, ok := luaTableBoolField(expr, "relative"); ok && !raw {
prefix = "exact "
}
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "movewindowpixel", params + "," + window
}
return "moveactive", params
case luaTableStringField(expr, "into_group") != "":
return "moveintogroup", luaTableStringField(expr, "into_group")
case luaTableStringField(expr, "into_or_create_group") != "":
return "moveintoorcreategroup", luaTableStringField(expr, "into_or_create_group")
case luaTableBoolFieldValue(expr, "out_of_group"):
return "moveoutofgroup", ""
case luaTableStringField(expr, "out_of_group") != "":
return "moveoutofgroup", luaTableStringField(expr, "out_of_group")
case luaTableStringField(expr, "direction") != "": case luaTableStringField(expr, "direction") != "":
if luaTableBoolFieldValue(expr, "group_aware") {
return "movewindoworgroup", luaTableStringField(expr, "direction")
}
return "movewindow", luaTableStringField(expr, "direction") return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "": case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor") return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "": case luaTableStringField(expr, "workspace") != "":
return "movetoworkspace", luaTableStringField(expr, "workspace") action := "movetoworkspace"
if follow, ok := luaTableBoolField(expr, "follow"); ok && !follow {
action = "movetoworkspacesilent"
}
return joinDispatcherParams(action, luaTableStringField(expr, "workspace"), luaTableStringField(expr, "window"))
} }
case expr == "hl.dsp.window.drag()": case expr == "hl.dsp.window.drag()":
return "movewindow", "" return "movewindow", ""
@@ -1052,25 +1214,184 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if y == "" { if y == "" {
y = "0" y = "0"
} }
return "resizeactive", x + " " + y prefix := ""
if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative {
prefix = "exact "
} }
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "resizewindowpixel", params + "," + window
}
return "resizeactive", params
}
case strings.HasPrefix(expr, "hl.dsp.window.swap("):
switch {
case luaTableBoolFieldValue(expr, "next"):
return "swapnext", ""
case luaTableBoolFieldValue(expr, "prev"):
return "swapnext", "prev"
}
return "swapwindow", luaTableStringField(expr, "direction")
case strings.HasPrefix(expr, "hl.dsp.window.cycle_next("):
parts := []string{}
if next, ok := luaTableBoolField(expr, "next"); ok && !next {
parts = append(parts, "prev")
}
if luaTableBoolFieldValue(expr, "tiled") {
parts = append(parts, "tiled")
}
if luaTableBoolFieldValue(expr, "floating") {
parts = append(parts, "floating")
}
return "cyclenext", strings.Join(parts, " ")
case strings.HasPrefix(expr, "hl.dsp.window.signal("):
signal := luaStringValue(luaTableScalarField(expr, "signal"))
window := luaTableStringField(expr, "window")
if window != "" {
return joinDispatcherParams("signalwindow", window, signal)
}
return "signal", signal
case strings.HasPrefix(expr, "hl.dsp.window.tag("):
return joinDispatcherParams("tagwindow", luaTableStringField(expr, "tag"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("):
mode := luaTableStringField(expr, "mode")
if mode == "" {
mode = luaTableStringField(expr, "zheight")
}
return joinDispatcherParams("alterzorder", mode, luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.set_prop("):
prop := luaTableStringField(expr, "prop")
if prop == "" {
prop = luaTableStringField(expr, "property")
}
return joinDispatcherParams("setprop", luaTableStringField(expr, "window"), prop, luaTableStringField(expr, "value"))
case strings.HasPrefix(expr, "hl.dsp.workspace.rename("):
return joinDispatcherParams("renameworkspace", luaTableStringField(expr, "workspace"), luaTableStringField(expr, "name"))
case strings.HasPrefix(expr, "hl.dsp.workspace.move("):
workspace := luaTableStringField(expr, "workspace")
monitor := luaTableStringField(expr, "monitor")
if workspace != "" {
return joinDispatcherParams("moveworkspacetomonitor", workspace, monitor)
}
return "movecurrentworkspacetomonitor", monitor
case strings.HasPrefix(expr, "hl.dsp.workspace.swap_monitors("):
return joinDispatcherParams("swapactiveworkspaces", luaTableStringField(expr, "monitor1"), luaTableStringField(expr, "monitor2"))
case strings.HasPrefix(expr, "hl.dsp.workspace.toggle_special("):
return "togglespecialworkspace", luaCallStringArgValue(expr, "hl.dsp.workspace.toggle_special")
case strings.HasPrefix(expr, "hl.dsp.layout("): case strings.HasPrefix(expr, "hl.dsp.layout("):
arg := extractLuaCallStringArg(expr, "hl.dsp.layout") if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" {
if arg != "" { return "layoutmsg", arg
if u, err := strconv.Unquote(arg); err == nil {
return "layoutmsg", u
}
} }
case strings.HasPrefix(expr, "hl.dsp.dpms("): case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" { if action := luaTableStringField(expr, "action"); action != "" {
switch action {
case "enable":
return "dpms", "on"
case "disable":
return "dpms", "off"
}
return "dpms", action return "dpms", action
} }
return "dpms", ""
case strings.HasPrefix(expr, "hl.dsp.submap("):
return "submap", luaCallStringArgValue(expr, "hl.dsp.submap")
case strings.HasPrefix(expr, "hl.dsp.global("):
return "global", luaCallStringArgValue(expr, "hl.dsp.global")
case strings.HasPrefix(expr, "hl.dsp.event("):
return "event", luaCallStringArgValue(expr, "hl.dsp.event")
case strings.HasPrefix(expr, "hl.dsp.pass("):
if window := luaTableStringField(expr, "window"); window != "" {
return "pass", window
}
return "pass", luaCallStringArgValue(expr, "hl.dsp.pass")
case strings.HasPrefix(expr, "hl.dsp.send_shortcut("):
return joinDispatcherParams("sendshortcut", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.send_key_state("):
return joinDispatcherParams("sendkeystate", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "state"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move_to_corner("):
return "movecursortocorner", luaStringValue(luaTableScalarField(expr, "corner"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move("):
return joinDispatcherParams("movecursor", luaStringValue(luaTableScalarField(expr, "x")), luaStringValue(luaTableScalarField(expr, "y")))
case strings.Contains(expr, "hl.dsp.force_renderer_reload()"):
return "forcerendererreload", ""
case strings.HasPrefix(expr, "hl.dsp.force_idle("):
return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle")
case strings.Contains(expr, "hl.dsp.exit()"): case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", "" return "exit", ""
default: default:
return "exec", "hyprctl dispatch lua:" + expr return expr, ""
}
return expr, ""
}
func splitDispatchCommand(command string) (dispatcher, params string) {
command = strings.TrimSpace(command)
if command == "" {
return "", ""
}
parts := strings.SplitN(command, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.TrimSpace(parts[1])
}
func joinDispatcherParams(dispatcher string, values ...string) (string, string) {
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return dispatcher, strings.Join(parts, " ")
}
func luaEmbeddedCallStringArgValue(expr, funcName string) string {
idx := strings.Index(expr, funcName+"(")
if idx < 0 {
return ""
}
return luaCallStringArgValue(expr[idx:], funcName)
}
func luaCallScalarArgValue(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "("
if !strings.HasPrefix(callExpr, prefix) {
return ""
}
inner := strings.TrimSpace(callExpr[len(prefix):])
if inner == "" {
return ""
}
if s := luaCallStringArgValue(callExpr, funcName); s != "" {
return s
}
re := regexp.MustCompile(`^-?\d+(?:\.\d+)?`)
return re.FindString(inner)
}
func luaToggleActionToLegacy(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func luaToggleActionToLockArg(action string) string {
switch luaToggleActionToLegacy(action) {
case "on":
return "lock"
case "off":
return "unlock"
default:
return "toggle"
} }
return "exec", "hyprctl dispatch lua:" + expr
} }
func extractLuaCallStringArg(callExpr, funcName string) string { func extractLuaCallStringArg(callExpr, funcName string) string {
@@ -1100,10 +1421,46 @@ func extractLuaCallStringArg(callExpr, funcName string) string {
return "" return ""
} }
func luaCallStringArgValue(callExpr, funcName string) string {
arg := extractLuaCallStringArg(callExpr, funcName)
if arg == "" {
return ""
}
u, err := strconv.Unquote(arg)
if err != nil {
return ""
}
return u
}
func luaTableStringField(expr, field string) string { func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field)) return luaStringValue(luaTableScalarField(expr, field))
} }
func luaTableModsField(expr string) string {
if mods := luaTableStringField(expr, "mods"); mods != "" {
return mods
}
return luaTableStringField(expr, "mod")
}
func luaTableBoolFieldValue(expr, field string) bool {
value, ok := luaTableBoolField(expr, field)
return ok && value
}
func luaTableBoolField(expr, field string) (bool, bool) {
raw := strings.ToLower(luaTableScalarField(expr, field))
switch raw {
case "true":
return true, true
case "false":
return false, true
default:
return false, false
}
}
func luaTableScalarField(expr, field string) string { func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`) re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr) m := re.FindStringSubmatch(expr)
@@ -1136,8 +1493,38 @@ func luaStringValue(raw string) string {
} }
func luaLineTrailingComment(line string) string { func luaLineTrailingComment(line string) string {
if idx := strings.Index(line, "--"); idx >= 0 { inString := byte(0)
return strings.TrimSpace(line[idx+2:]) escaped := false
for i := 0; i < len(line)-1; i++ {
c := line[i]
if inString != 0 {
if escaped {
escaped = false
continue
}
if c == '\\' && inString == '"' {
escaped = true
continue
}
if c == inString {
inString = 0
}
continue
}
if c == '"' || c == '\'' {
inString = c
continue
}
if c == '[' && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return ""
}
if c == '-' && line[i+1] == '-' {
return strings.TrimSpace(line[i+2:])
}
} }
return "" return ""
} }
@@ -70,12 +70,37 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
wantParams string wantParams string
}{ }{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`}, {`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
{`hl.dsp.exec_cmd([[hyprctl dispatch workspace 1]])`, "workspace", "1"},
{`hl.dispatch("workspace 2")`, "workspace", "2"},
{`hl.dispatch([[customdispatcher arg one]])`, "customdispatcher", "arg one"},
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"}, {`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.window.float({ action = "on" })`, "setfloating", ""},
{`hl.dsp.window.close()`, "killactive", ""},
{`hl.dsp.window.kill()`, "forcekillactive", ""},
{`hl.dsp.window.close({ window = "class:^(kitty)$" })`, "closewindow", "class:^(kitty)$"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"}, {`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
{`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"},
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"}, {`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"}, {`hl.dsp.window.move({ direction = "r", group_aware = true })`, "movewindoworgroup", "r"},
{`hl.dsp.window.move({ into_group = "l" })`, "moveintogroup", "l"},
{`hl.dsp.window.move({ out_of_group = true })`, "moveoutofgroup", ""},
{`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "movetoworkspacesilent", "special:magic"},
{`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "resizeactive", "-100 0"},
{`hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`, "resizeactive", "exact 1280 720"},
{`hl.dsp.window.resize({ x = 100, y = 50, relative = true, window = "class:^(app)$" })`, "resizewindowpixel", "100 50,class:^(app)$"},
{`hl.dsp.window.cycle_next({ next = false, tiled = true })`, "cyclenext", "prev tiled"},
{`hl.dsp.group.next()`, "changegroupactive", "f"},
{`hl.dsp.group.prev()`, "changegroupactive", "b"},
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
{`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"},
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"}, {`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"}, {`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
{`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"},
{`hl.dsp.no_op()`, "hl.dsp.no_op()", ""},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -113,12 +138,132 @@ func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
}) })
want := `hl.unbind("SUPER + N") want := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle` hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { description = "Notepad: Toggle" })`
if got := strings.TrimSpace(sb.String()); got != want { if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want) t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
} }
} }
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
})
want := `hl.unbind("SUPER + U")
hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
tests := []struct {
action string
want string
}{
{"killactive", `hl.dsp.window.close()`},
{"forcekillactive", `hl.dsp.window.kill()`},
{"workspace 1", `hl.dsp.focus({ workspace = "1" })`},
{"movetoworkspace 2", `hl.dsp.window.move({ workspace = "2" })`},
{"movetoworkspacesilent special:magic", `hl.dsp.window.move({ workspace = "special:magic", follow = false })`},
{"focusmonitor DP-1", `hl.dsp.focus({ monitor = "DP-1" })`},
{"resizeactive exact 1280 720", `hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`},
{"dpms toggle", `hl.dsp.dpms({ action = "toggle" })`},
{"renameworkspace 1 work", `hl.dsp.workspace.rename({ workspace = "1", name = "work" })`},
{"changegroupactive f", `hl.dsp.group.next()`},
{"changegroupactive b", `hl.dsp.group.prev()`},
{"changegroupactive 2", `hl.dsp.group.active({ index = 2 })`},
{"moveintogroup l", `hl.dsp.window.move({ into_group = "l" })`},
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
{"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`},
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
{"setfloating", `hl.dsp.window.float({ action = "on" })`},
{"settiled", `hl.dsp.window.float({ action = "off" })`},
{"bringactivetotop", `hl.dsp.window.bring_to_top()`},
{"toggleswallow", `hl.dsp.window.toggle_swallow()`},
{"forceidle 300", `hl.dsp.force_idle(300)`},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
got := luaActionStringFromHyprlangAction(tt.action)
if got != tt.want {
t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want %q", tt.action, got, tt.want)
}
if strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected native Lua dispatcher, got legacy dispatch wrapper: %q", got)
}
})
}
}
func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) {
got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%")
want := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
}
func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
line := `hl.bind("SUPER + R", function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end, { description = "Unsupported Resize" })`
got, ok := parseLuaBindOverrideLine(line)
if !ok {
t.Fatalf("expected line to parse")
}
if got.Action != "resizeactive exact 100% 100%" {
t.Fatalf("Action = %q, want resizeactive exact 100%% 100%%", got.Action)
}
if got.Description != "Unsupported Resize" {
t.Fatalf("Description = %q, want Unsupported Resize", got.Description)
}
}
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
want := `hl.dsp.no_op()`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
}
}
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua")
contents := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle
hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help"))
`
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
binds, err := readLuaOrHyprlangOverride(overridePath)
if err != nil {
t.Fatal(err)
}
got := binds["super+n"]
if got == nil {
t.Fatalf("expected SUPER+N override, got %#v", binds)
}
if got.Description != "Notepad: Toggle" {
t.Fatalf("expected trailing comment to be preserved as description, got %q", got.Description)
}
if got := binds["super+h"]; got == nil || got.Description != "" {
t.Fatalf("expected -- inside a Lua string to stay out of the description, got %#v", got)
}
}
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) { func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
@@ -283,6 +428,64 @@ func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
} }
} }
func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil)
if err == nil {
t.Fatal("expected SetBind to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `-- DMS user keybind overrides
hl.unbind("SUPER + SHIFT + S")
hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.SetBind("SUPER + 1", "workspace 1", "", nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, `hl.unbind("SUPER + 1")`) != 1 {
t.Fatalf("expected one SUPER+1 unbind, got:\n%s", got)
}
if strings.Count(got, `hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))`) != 1 {
t.Fatalf("expected one native SUPER+1 bind, got:\n%s", got)
}
if strings.Contains(got, "hyprctl dispatch workspace 1") {
t.Fatalf("expected old hyprctl workspace dispatcher to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `hl.unbind("SUPER + SHIFT + S")`) {
t.Fatalf("expected unrelated override to be preserved, got:\n%s", got)
}
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) { func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
+253 -33
View File
@@ -7,6 +7,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
@@ -228,11 +229,20 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
} }
normalizedKey := strings.ToLower(key) normalizedKey := strings.ToLower(key)
prefix := "bind"
if existing, ok := existingBinds[normalizedKey]; ok && existing.Prefix != "" {
prefix = existing.Prefix
}
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
prefix = optionPrefix
}
existingBinds[normalizedKey] = &mangowcOverrideBind{ existingBinds[normalizedKey] = &mangowcOverrideBind{
Key: key, Key: key,
Action: action, Action: action,
Description: description, Description: description,
Options: options, Options: options,
Prefix: prefix,
} }
return m.writeOverrideBinds(existingBinds) return m.writeOverrideBinds(existingBinds)
@@ -246,7 +256,7 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
normalizedKey := strings.ToLower(key) normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey) delete(existingBinds, normalizedKey)
return m.writeOverrideBinds(existingBinds) return m.writeOverrideBindsWithRemoved(existingBinds, map[string]bool{normalizedKey: true})
} }
func (m *MangoWCProvider) ResetBind(key string) error { func (m *MangoWCProvider) ResetBind(key string) error {
@@ -258,6 +268,7 @@ type mangowcOverrideBind struct {
Action string Action string
Description string Description string
Options map[string]any Options map[string]any
Prefix string
} }
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) { func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
@@ -272,34 +283,63 @@ func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind,
return nil, err return nil, err
} }
lines := strings.Split(string(data), "\n") var pendingComment string
for _, line := range lines { for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue continue
} }
if !strings.HasPrefix(line, "bind") { bind, ok := m.parseOverrideBindLine(line, pendingComment)
pendingComment = ""
if !ok || bind == nil {
continue continue
} }
parts := strings.SplitN(line, "=", 2) binds[strings.ToLower(bind.Key)] = bind
}
return binds, nil
}
func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (*mangowcOverrideBind, bool) {
trimmed := strings.TrimSpace(line)
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 { if len(parts) < 2 {
continue return nil, false
}
prefix := strings.TrimSpace(parts[0])
if !m.isBindPrefix(prefix) {
return nil, false
} }
content := strings.TrimSpace(parts[1]) content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2) commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0]) bindContent := strings.TrimSpace(commentParts[0])
var comment string description := strings.TrimSpace(precedingComment)
if isMangoWCSectionComment(description) {
description = ""
}
if len(commentParts) > 1 { if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1]) description = strings.TrimSpace(commentParts[1])
}
if strings.HasPrefix(description, MangoWCHideComment) {
return nil, true
} }
fields := strings.SplitN(bindContent, ",", 4) fields := strings.SplitN(bindContent, ",", 4)
if len(fields) < 3 { if len(fields) < 3 {
continue return nil, false
} }
mods := strings.TrimSpace(fields[0]) mods := strings.TrimSpace(fields[0])
@@ -311,21 +351,29 @@ func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind,
params = strings.TrimSpace(fields[3]) params = strings.TrimSpace(fields[3])
} }
keyStr := m.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := command action := command
if params != "" { if params != "" {
action = command + " " + params action = command + " " + params
} }
binds[normalizedKey] = &mangowcOverrideBind{ return &mangowcOverrideBind{
Key: keyStr, Key: m.buildKeyString(mods, keyName),
Action: action, Action: action,
Description: comment, Description: description,
} Prefix: prefix,
}, true
} }
return binds, nil func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
if !strings.HasPrefix(prefix, "bind") {
return false
}
for _, ch := range strings.TrimPrefix(prefix, "bind") {
if !strings.ContainsRune("lsrp", ch) {
return false
}
}
return true
} }
func (m *MangoWCProvider) buildKeyString(mods, key string) string { func (m *MangoWCProvider) buildKeyString(mods, key string) string {
@@ -362,21 +410,113 @@ func (m *MangoWCProvider) getBindSortPriority(action string) int {
} }
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error { func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
return m.writeOverrideBindsWithRemoved(binds, nil)
}
func (m *MangoWCProvider) writeOverrideBindsWithRemoved(binds map[string]*mangowcOverrideBind, removed map[string]bool) error {
overridePath := m.GetOverridePath() overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds) existingContent := ""
if data, err := os.ReadFile(overridePath); err == nil {
existingContent = string(data)
}
content := m.generatePreservedBindsContent(existingContent, binds, removed)
return os.WriteFile(overridePath, []byte(content), 0o644) return os.WriteFile(overridePath, []byte(content), 0o644)
} }
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string { func (m *MangoWCProvider) generatePreservedBindsContent(existingContent string, binds map[string]*mangowcOverrideBind, removed map[string]bool) string {
if len(binds) == 0 { useStockScaffold := m.shouldUseStockScaffold(existingContent)
return "" source := existingContent
if useStockScaffold {
source = m.stockBindsScaffold(binds)
} }
remaining := make(map[string]*mangowcOverrideBind, len(binds))
for key, bind := range binds {
remaining[key] = bind
}
if useStockScaffold {
m.dropReplacedStockBinds(remaining)
}
var lines []string
for _, line := range strings.Split(source, "\n") {
templateBind, ok := m.parseOverrideBindLine(line, m.previousComment(lines))
if !ok || templateBind == nil {
lines = append(lines, line)
continue
}
normalizedKey := strings.ToLower(templateBind.Key)
m.dropPreviousDescriptionComment(&lines)
if bind, exists := remaining[normalizedKey]; exists {
if useStockScaffold && bind.Description == "" {
bind = m.copyBindWithDescription(bind, templateBind.Description)
}
m.writeBindLineToLines(&lines, bind)
delete(remaining, normalizedKey)
continue
}
if useStockScaffold && !removed[normalizedKey] {
m.writeBindLineToLines(&lines, templateBind)
}
}
if len(remaining) > 0 {
m.trimTrailingEmptyLines(&lines)
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, "# === Custom Keybinds ===")
for _, bind := range m.sortedBinds(remaining) {
m.writeBindLineToLines(&lines, bind)
}
}
m.trimTrailingEmptyLines(&lines)
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n") + "\n"
}
func (m *MangoWCProvider) shouldUseStockScaffold(content string) bool {
if strings.TrimSpace(content) == "" {
return true
}
if strings.Contains(content, "gesturebind=") && strings.Contains(content, "# ===") {
return false
}
return !strings.Contains(content, "gesturebind=") && (strings.Count(content, "\nbind=")+strings.Count(content, "\nbindl=")+strings.Count(content, "\nbinds=")+strings.Count(content, "\nbindr=")+strings.Count(content, "\nbindp=") >= 10 || strings.Contains(content, "dms ipc call"))
}
func (m *MangoWCProvider) stockBindsScaffold(binds map[string]*mangowcOverrideBind) string {
terminalCommand := "ghostty"
for _, key := range []string{"super+t", "super+return"} {
if bind, ok := binds[key]; ok {
command, params := m.parseAction(bind.Action)
if command == "spawn" && strings.TrimSpace(params) != "" && !strings.Contains(params, "dms ") {
terminalCommand = params
break
}
}
}
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
}
func (m *MangoWCProvider) dropReplacedStockBinds(binds map[string]*mangowcOverrideBind) {
if bind, ok := binds["super+j"]; ok && bind.Action == "switch_layout" {
delete(binds, "super+j")
}
}
func (m *MangoWCProvider) sortedBinds(binds map[string]*mangowcOverrideBind) []*mangowcOverrideBind {
bindList := make([]*mangowcOverrideBind, 0, len(binds)) bindList := make([]*mangowcOverrideBind, 0, len(binds))
for _, bind := range binds { for _, bind := range binds {
bindList = append(bindList, bind) bindList = append(bindList, bind)
} }
sort.Slice(bindList, func(i, j int) bool { sort.Slice(bindList, func(i, j int) bool {
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action) pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
if pi != pj { if pi != pj {
@@ -384,20 +524,75 @@ func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverride
} }
return bindList[i].Key < bindList[j].Key return bindList[i].Key < bindList[j].Key
}) })
return bindList
var sb strings.Builder
for _, bind := range bindList {
m.writeBindLine(&sb, bind)
} }
return sb.String() func (m *MangoWCProvider) writeBindLineToLines(lines *[]string, bind *mangowcOverrideBind) {
var sb strings.Builder
m.writeBindLine(&sb, bind)
text := strings.TrimSuffix(sb.String(), "\n")
if text == "" {
return
}
*lines = append(*lines, strings.Split(text, "\n")...)
}
func (m *MangoWCProvider) previousComment(lines []string) string {
if len(lines) == 0 {
return ""
}
trimmed := strings.TrimSpace(lines[len(lines)-1])
if !strings.HasPrefix(trimmed, "#") {
return ""
}
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(comment) {
return ""
}
return comment
}
func (m *MangoWCProvider) dropPreviousDescriptionComment(lines *[]string) {
if len(*lines) == 0 {
return
}
trimmed := strings.TrimSpace((*lines)[len(*lines)-1])
if !strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "# ===") {
return
}
*lines = (*lines)[:len(*lines)-1]
}
func (m *MangoWCProvider) trimTrailingEmptyLines(lines *[]string) {
for len(*lines) > 0 && strings.TrimSpace((*lines)[len(*lines)-1]) == "" {
*lines = (*lines)[:len(*lines)-1]
}
}
func (m *MangoWCProvider) copyBindWithDescription(bind *mangowcOverrideBind, description string) *mangowcOverrideBind {
copy := *bind
copy.Description = description
return &copy
} }
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) { func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
mods, key := m.parseKeyString(bind.Key) mods, key := m.parseKeyString(bind.Key)
command, params := m.parseAction(bind.Action) command, params := m.parseAction(bind.Action)
sb.WriteString("bind=") // Description goes on the line ABOVE the bind: mango doesn't strip inline `#`
// comments from a value, so a trailing comment would break spawn (extra argv).
if bind.Description != "" {
sb.WriteString("# ")
sb.WriteString(bind.Description)
sb.WriteString("\n")
}
prefix := bind.Prefix
if prefix == "" {
prefix = "bind"
}
sb.WriteString(prefix)
sb.WriteString("=")
if mods == "" { if mods == "" {
sb.WriteString("none") sb.WriteString("none")
} else { } else {
@@ -413,12 +608,37 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
sb.WriteString(params) sb.WriteString(params)
} }
if bind.Description != "" { sb.WriteString("\n")
sb.WriteString(" # ")
sb.WriteString(bind.Description)
} }
sb.WriteString("\n") func (m *MangoWCProvider) bindPrefixFromOptions(options map[string]any) string {
if options == nil {
return ""
}
value, ok := options["flags"]
if !ok {
return ""
}
flags := ""
switch v := value.(type) {
case string:
flags = v
case fmt.Stringer:
flags = v.String()
default:
return ""
}
flags = strings.TrimSpace(flags)
if flags == "" {
return "bind"
}
var clean strings.Builder
for _, ch := range flags {
if strings.ContainsRune("lsrp", ch) && !strings.ContainsRune(clean.String(), ch) {
clean.WriteRune(ch)
}
}
return "bind" + clean.String()
} }
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) { func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
@@ -15,6 +15,10 @@ const (
var MangoWCModSeparators = []rune{'+', ' '} var MangoWCModSeparators = []rune{'+', ' '}
func isMangoWCSectionComment(comment string) bool {
return strings.HasPrefix(strings.TrimSpace(comment), "===")
}
type MangoWCKeyBinding struct { type MangoWCKeyBinding struct {
Mods []string `json:"mods"` Mods []string `json:"mods"`
Key string `json:"key"` Key string `json:"key"`
@@ -216,101 +220,40 @@ func mangowcAutogenerateComment(command, params string) string {
} }
} }
func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding { func (p *MangoWCParser) getKeybindAtLine(lineNumber int, precedingComment string) *MangoWCKeyBinding {
if lineNumber >= len(p.contentLines) { if lineNumber >= len(p.contentLines) {
return nil return nil
} }
return p.getKeybindAtLineContent(p.contentLines[lineNumber], precedingComment)
line := p.contentLines[lineNumber]
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
bindType := matches[1]
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = mangowcAutogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(MangoWCModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range MangoWCModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
_ = bindType
return &MangoWCKeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
} }
func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding { func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
var keybinds []MangoWCKeyBinding var keybinds []MangoWCKeyBinding
var pendingComment string
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ { for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
line := p.contentLines[lineNumber] trimmed := strings.TrimSpace(p.contentLines[lineNumber])
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue
}
if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
continue continue
} }
if !strings.HasPrefix(strings.TrimSpace(line), "bind") { keybind := p.getKeybindAtLine(lineNumber, pendingComment)
continue
}
keybind := p.getKeybindAtLine(lineNumber)
if keybind != nil { if keybind != nil {
keybinds = append(keybinds, *keybind) keybinds = append(keybinds, *keybind)
} }
pendingComment = ""
} }
return keybinds return keybinds
@@ -459,21 +402,38 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
p.currentSource = absPath p.currentSource = absPath
var keybinds []MangoWCKeyBinding var keybinds []MangoWCKeyBinding
var pendingComment string
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
for lineNum, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "source") { if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds) p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue continue
} }
if !strings.HasPrefix(trimmed, "bind") { if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
continue continue
} }
kb := p.getKeybindAtLineContent(line, lineNum) kb := p.getKeybindAtLineContent(line, pendingComment)
pendingComment = ""
if kb == nil { if kb == nil {
continue continue
} }
@@ -529,8 +489,11 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
return keybinds return keybinds
} }
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding { // getKeybindAtLineContent parses one `bind=` line. precedingComment (a `# ...`
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`) // line directly above) is the description: mango feeds inline comments to spawn
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line) matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 { if len(matches) < 3 {
return nil return nil
@@ -544,6 +507,12 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyB
if len(parts) > 1 { if len(parts) > 1 {
comment = strings.TrimSpace(parts[1]) comment = strings.TrimSpace(parts[1])
} }
if comment == "" {
comment = strings.TrimSpace(precedingComment)
if isMangoWCSectionComment(comment) {
comment = ""
}
}
if strings.HasPrefix(comment, MangoWCHideComment) { if strings.HasPrefix(comment, MangoWCHideComment) {
return nil return nil
@@ -73,6 +73,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
line string line string
precedingComment string
expected *MangoWCKeyBinding expected *MangoWCKeyBinding
}{ }{
{ {
@@ -157,6 +158,41 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
Comment: "dms ipc call lock lock", Comment: "dms ipc call lock lock",
}, },
}, },
{
name: "bindp_flag",
line: "bindp=SUPER,p,spawn,pass-through",
expected: &MangoWCKeyBinding{
Mods: []string{"SUPER"},
Key: "p",
Command: "spawn",
Params: "pass-through",
Comment: "pass-through",
},
},
{
name: "preceding_comment",
line: "bind=SUPER+SHIFT,S,spawn,dms screenshot",
precedingComment: "Screenshot: Interactive",
expected: &MangoWCKeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "S",
Command: "spawn",
Params: "dms screenshot",
Comment: "Screenshot: Interactive",
},
},
{
name: "section_header_not_description",
line: "bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3",
precedingComment: "=== Audio Controls ===",
expected: &MangoWCKeyBinding{
Mods: []string{},
Key: "XF86AudioRaiseVolume",
Command: "spawn",
Params: "dms ipc call audio increment 3",
Comment: "dms ipc call audio increment 3",
},
},
{ {
name: "keybind_with_spaces", name: "keybind_with_spaces",
line: "bind = SUPER, r, reload_config", line: "bind = SUPER, r, reload_config",
@@ -174,7 +210,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("") parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0, tt.precedingComment)
if tt.expected == nil { if tt.expected == nil {
if result != nil { if result != nil {
@@ -421,7 +457,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("") parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0, "")
if result != nil { if result != nil {
t.Errorf("expected nil for invalid line, got %+v", result) t.Errorf("expected nil for invalid line, got %+v", result)
@@ -3,7 +3,10 @@ package providers
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
) )
func TestMangoWCProviderName(t *testing.T) { func TestMangoWCProviderName(t *testing.T) {
@@ -318,3 +321,138 @@ bind=Ctrl,1,view,1,0
t.Error("Did not find terminal keybind with correct key and description") t.Error("Did not find terminal keybind with correct key and description")
} }
} }
func TestMangoWCSetBindPreservesStockCommentsAndGestures(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
t.Fatalf("failed to write stock binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
for _, want := range []string{
"# === Application Launchers ===",
"# === Touchpad Gestures ===",
"gesturebind=none,right,3,viewtoleft_have_client",
"gesturebind=none,left,3,viewtoright_have_client",
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected saved binds to contain %q\ncontent:\n%s", want, content)
}
}
if strings.Contains(content, "# === Audio Controls ===\n# === Audio Controls ===") {
t.Fatalf("section header should not be duplicated as a bind description\ncontent:\n%s", content)
}
}
func TestMangoWCSetBindRestoresScaffoldForStrippedFile(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stripped := `bind=SUPER,t,spawn,ghostty
bind=SUPER,Return,spawn,ghostty
bind=SUPER,space,spawn,dms ipc call spotlight toggle
bind=SUPER,v,spawn,dms ipc call clipboard toggle
bind=SUPER,q,killclient
bind=SUPER,Left,focusdir,left
bind=SUPER,Right,focusdir,right
bind=SUPER,Up,focusdir,up
bind=SUPER,Down,focusdir,down
bind=SUPER,1,view,1
bind=SUPER,2,view,2
bind=SUPER,3,view,3
`
if err := os.WriteFile(bindsPath, []byte(stripped), 0o644); err != nil {
t.Fatalf("failed to write stripped binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
for _, want := range []string{
"# DMS default keybinds (MangoWM)",
"# === Touchpad Gestures ===",
"gesturebind=none,right,3,viewtoleft_have_client",
"bind=SUPER,H,focusdir,left",
"bind=SUPER,J,focusdir,down",
"bind=SUPER,K,focusdir,up",
"bind=SUPER,L,focusdir,right",
"# === Custom Keybinds ===",
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
"bind=SUPER,t,spawn,ghostty",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected restored binds to contain %q\ncontent:\n%s", want, content)
}
}
if strings.Contains(content, "{{TERMINAL_COMMAND}}") {
t.Fatalf("terminal placeholder should have been resolved\ncontent:\n%s", content)
}
}
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
t.Fatalf("failed to write stock binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.RemoveBind("SUPER+Tab"); err != nil {
t.Fatalf("RemoveBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
if strings.Contains(content, "bind=SUPER,Tab,focusstack,next") {
t.Fatalf("removed bind should be absent\ncontent:\n%s", content)
}
if strings.Contains(content, "# Focus Next Window") {
t.Fatalf("removed bind description should be absent\ncontent:\n%s", content)
}
for _, want := range []string{
"# === Focus Navigation ===",
"# === Touchpad Gestures ===",
"gesturebind=none,down,4,toggleoverview",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected non-bind line %q to be preserved\ncontent:\n%s", want, content)
}
}
}
+2
View File
@@ -25,6 +25,8 @@ type DMSBindsStatus struct {
Effective bool `json:"effective"` Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"` OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"` StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
type CheatSheet struct { type CheatSheet struct {
@@ -2,6 +2,7 @@ package clipboard
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net" "net"
@@ -73,6 +74,10 @@ func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
entry, err := m.GetEntry(uint64(id)) entry, err := m.GetEntry(uint64(id))
if err != nil { if err != nil {
if errors.Is(err, errEntryNotFound) {
models.Respond[any](conn, req.ID, nil)
return
}
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
return return
} }
+25 -6
View File
@@ -3,6 +3,7 @@ package clipboard
import ( import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"image" "image"
_ "image/gif" _ "image/gif"
@@ -34,6 +35,8 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
var errEntryNotFound = errors.New("entry not found")
// These mime types won't be stored in history // These mime types won't be stored in history
var sensitiveMimeTypes = []string{ var sensitiveMimeTypes = []string{
"x-kde-passwordManagerHint", "x-kde-passwordManagerHint",
@@ -572,16 +575,16 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string { func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{ preferredTypes := []string{
"text/uri-list", "text/uri-list",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
"image/png", "image/png",
"image/jpeg", "image/jpeg",
"image/gif", "image/gif",
"image/bmp", "image/bmp",
"image/tiff", "image/tiff",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
} }
for _, pref := range preferredTypes { for _, pref := range preferredTypes {
@@ -764,9 +767,25 @@ func stateEqual(a, b *State) bool {
if len(a.History) != len(b.History) { if len(a.History) != len(b.History) {
return false return false
} }
for i := range a.History {
if !entryStateEqual(a.History[i], b.History[i]) {
return false
}
}
return true return true
} }
func entryStateEqual(a, b Entry) bool {
return a.ID == b.ID &&
a.Hash == b.Hash &&
a.Pinned == b.Pinned &&
a.IsImage == b.IsImage &&
a.MimeType == b.MimeType &&
a.Preview == b.Preview &&
a.Size == b.Size &&
a.Timestamp.Equal(b.Timestamp)
}
func (m *Manager) GetHistory() []Entry { func (m *Manager) GetHistory() []Entry {
if m.db == nil { if m.db == nil {
return nil return nil
@@ -854,7 +873,7 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
return nil, err return nil, err
} }
if !found { if !found {
return nil, fmt.Errorf("entry not found") return nil, errEntryNotFound
} }
return &entry, nil return &entry, nil
+141 -2
View File
@@ -1,17 +1,52 @@
package clipboard package clipboard
import ( import (
"bytes"
"encoding/json"
"net"
"path/filepath"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext" mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
) )
type clipboardTestConn struct {
net.Conn
writeBuf *bytes.Buffer
}
func newClipboardTestConn() *clipboardTestConn {
return &clipboardTestConn{writeBuf: &bytes.Buffer{}}
}
func (c *clipboardTestConn) Write(b []byte) (int, error) {
return c.writeBuf.Write(b)
}
func newTestManagerWithDB(t *testing.T) *Manager {
t.Helper()
db, err := openDB(filepath.Join(t.TempDir(), "clipboard.db"))
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return &Manager{
config: DefaultConfig(),
db: db,
}
}
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
original := Entry{ original := Entry{
ID: 12345, ID: 12345,
@@ -131,11 +166,113 @@ func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
} }
func TestStateEqual_BothEqual(t *testing.T) { func TestStateEqual_BothEqual(t *testing.T) {
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}} ts := time.Now().Truncate(time.Second)
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}} entry := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: true,
}
a := &State{Enabled: true, History: []Entry{entry}}
b := &State{Enabled: true, History: []Entry{entry}}
assert.True(t, stateEqual(a, b)) assert.True(t, stateEqual(a, b))
} }
func TestStateEqual_SameLengthDifferentIDs(t *testing.T) {
ts := time.Now().Truncate(time.Second)
a := &State{Enabled: true, History: []Entry{{ID: 1, Hash: 100, Timestamp: ts}}}
b := &State{Enabled: true, History: []Entry{{ID: 2, Hash: 100, Timestamp: ts}}}
assert.False(t, stateEqual(a, b))
}
func TestStateEqual_MetadataDiffers(t *testing.T) {
ts := time.Now().Truncate(time.Second)
base := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: false,
}
tests := []struct {
name string
mutate func(*Entry)
}{
{name: "hash", mutate: func(e *Entry) { e.Hash = 101 }},
{name: "pinned", mutate: func(e *Entry) { e.Pinned = true }},
{name: "is image", mutate: func(e *Entry) { e.IsImage = false }},
{name: "mime type", mutate: func(e *Entry) { e.MimeType = "image/jpeg" }},
{name: "preview", mutate: func(e *Entry) { e.Preview = "[[ image 2 KiB jpeg 64x64 ]]" }},
{name: "size", mutate: func(e *Entry) { e.Size = 2048 }},
{name: "timestamp", mutate: func(e *Entry) { e.Timestamp = ts.Add(time.Second) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
changed := base
tt.mutate(&changed)
a := &State{Enabled: true, History: []Entry{base}}
b := &State{Enabled: true, History: []Entry{changed}}
assert.False(t, stateEqual(a, b))
})
}
}
func TestHandleGetEntry_ReturnsExistingEntry(t *testing.T) {
m := newTestManagerWithDB(t)
err := m.storeEntry(Entry{
Data: []byte("hello world"),
MimeType: "text/plain;charset=utf-8",
Preview: "hello world",
Size: len("hello world"),
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
})
require.NoError(t, err)
history := m.GetHistory()
require.Len(t, history, 1)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(history[0].ID)},
}, m)
var resp models.Response[Entry]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.Equal(t, history[0].ID, resp.Result.ID)
assert.Equal(t, []byte("hello world"), resp.Result.Data)
}
func TestHandleGetEntry_MissingIDReturnsNullResult(t *testing.T) {
m := newTestManagerWithDB(t)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(999)},
}, m)
var resp models.Response[any]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
assert.Nil(t, resp.Result)
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) { func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{ m := &Manager{
subscribers: make(map[string]chan State), subscribers: make(map[string]chan State),
@@ -410,6 +547,8 @@ func TestSelectMimeType(t *testing.T) {
{[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"}, {[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
{[]string{"text/html", "text/plain"}, "text/plain"}, {[]string{"text/html", "text/plain"}, "text/plain"},
{[]string{"text/html", "image/png"}, "image/png"}, {[]string{"text/html", "image/png"}, "image/png"},
{[]string{"image/png", "text/plain"}, "image/png"},
{[]string{"text/plain", "image/png"}, "image/png"},
{[]string{"image/png", "image/jpeg"}, "image/png"}, {[]string{"image/png", "image/jpeg"}, "image/png"},
{[]string{"image/png"}, "image/png"}, {[]string{"image/png"}, "image/png"},
{[]string{"application/octet-stream"}, "application/octet-stream"}, {[]string{"application/octet-stream"}, "application/octet-stream"},
@@ -27,16 +27,19 @@ type linkInfo struct {
} }
func (l *linkInfo) isWired() bool { func (l *linkInfo) isWired() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" { if l.linkType != "" {
return l.linkType == "ether" return l.linkType == "ether"
} }
if looksVirtual(l.name) || strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp") { return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
return false
}
return true
} }
func (l *linkInfo) isWireless() bool { func (l *linkInfo) isWireless() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" { if l.linkType != "" {
return l.linkType == "wlan" return l.linkType == "wlan"
} }
@@ -45,7 +48,7 @@ func (l *linkInfo) isWireless() bool {
func looksVirtual(name string) bool { func looksVirtual(name string) bool {
virtualPrefixes := []string{ virtualPrefixes := []string{
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap", "lo", "docker", "podman", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali", "vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
} }
for _, prefix := range virtualPrefixes { for _, prefix := range virtualPrefixes {
@@ -110,6 +113,12 @@ func (b *SystemdNetworkdBackend) Close() {
} }
} }
type enumeratedLink struct {
ifindex int32
name string
path dbus.ObjectPath
}
func (b *SystemdNetworkdBackend) enumerateLinks() error { func (b *SystemdNetworkdBackend) enumerateLinks() error {
obj := b.conn.Object(networkdBusName, b.managerPath) obj := b.conn.Object(networkdBusName, b.managerPath)
@@ -123,25 +132,48 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
return fmt.Errorf("ListLinks: %w", err) return fmt.Errorf("ListLinks: %w", err)
} }
fresh := make([]enumeratedLink, len(links))
for i, l := range links {
fresh[i] = enumeratedLink{ifindex: l.Ifindex, name: l.Name, path: l.Path}
}
b.linksMutex.Lock() b.linksMutex.Lock()
defer b.linksMutex.Unlock() defer b.linksMutex.Unlock()
b.syncLinks(fresh)
for _, l := range links { return nil
if existing, ok := b.links[l.Name]; ok && existing.path == l.Path { }
existing.ifindex = l.Ifindex
// syncLinks reconciles the cached link map against the freshly enumerated set:
// it adds links not seen before (querying their Type once), refreshes the
// ifindex of survivors, and prunes links that no longer appear. Pruning is what
// keeps torn-down container interfaces (podman bridges, veth pairs) from
// lingering as routable and being mistaken for the wired uplink.
// Callers must hold linksMutex.
func (b *SystemdNetworkdBackend) syncLinks(fresh []enumeratedLink) {
present := make(map[string]bool, len(fresh))
for _, l := range fresh {
present[l.name] = true
if existing, ok := b.links[l.name]; ok && existing.path == l.path {
existing.ifindex = l.ifindex
continue continue
} }
info := &linkInfo{ info := &linkInfo{
ifindex: l.Ifindex, ifindex: l.ifindex,
name: l.Name, name: l.name,
path: l.Path, path: l.path,
linkType: b.fetchLinkType(l.Path), linkType: b.fetchLinkType(l.path),
} }
b.links[l.Name] = info b.links[l.name] = info
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.Name, l.Ifindex, l.Path, info.linkType) log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.name, l.ifindex, l.path, info.linkType)
} }
return nil for name := range b.links {
if !present[name] {
log.Debugf("networkd: pruned stale link %s", name)
delete(b.links, name)
}
}
} }
// fetchLinkType queries networkd's Describe method and extracts the link Type // fetchLinkType queries networkd's Describe method and extracts the link Type
@@ -160,6 +160,12 @@ func TestLinkInfo_Classify(t *testing.T) {
{"loopback type", "lo", "loopback", false, false}, {"loopback type", "lo", "loopback", false, false},
{"none type (tun overlay)", "nebula.homelab", "none", false, false}, {"none type (tun overlay)", "nebula.homelab", "none", false, false},
{"none type (wireguard)", "wg0", "none", false, false}, {"none type (wireguard)", "wg0", "none", false, false},
// Virtual interfaces report Type=ether but must never be mistaken for
// the wired uplink — stale podman/veth links would otherwise poison
// ethernet detection.
{"veth ether excluded", "veth1234", "ether", false, false},
{"podman bridge ether excluded", "podman3", "ether", false, false},
{"docker bridge ether excluded", "docker0", "ether", false, false},
// Fallback path: linkType unavailable, name-prefix heuristic applies. // Fallback path: linkType unavailable, name-prefix heuristic applies.
{"fallback enp wired", "enp141s0", "", true, false}, {"fallback enp wired", "enp141s0", "", true, false},
{"fallback wlan wireless", "wlan0", "", false, true}, {"fallback wlan wireless", "wlan0", "", false, true},
@@ -205,8 +211,46 @@ func TestParseDescribeType(t *testing.T) {
} }
} }
func TestSyncLinks_PrunesRemovedLinks(t *testing.T) {
// Stale container interfaces (torn-down podman bridges, veth pairs) must
// not linger in the link map after they disappear from ListLinks — kept as
// routable, they stole the wired-uplink slot from the real ethernet NIC.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether", opState: "routable"},
"podman3": {ifindex: 9, name: "podman3", path: "/org/freedesktop/network1/link/_39", linkType: "ether", opState: "routable"},
"veth0": {ifindex: 10, name: "veth0", path: "/org/freedesktop/network1/link/_310", linkType: "ether", opState: "routable"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Contains(t, backend.links, "eno1")
assert.NotContains(t, backend.links, "podman3")
assert.NotContains(t, backend.links, "veth0")
}
func TestSyncLinks_RefreshesSurvivingLink(t *testing.T) {
// A link that survives keeps its cached Type — Describe is only queried for
// newly seen links — while picking up a refreshed ifindex.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 7, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Equal(t, int32(7), backend.links["eno1"].ifindex)
assert.Equal(t, "ether", backend.links["eno1"].linkType)
}
func TestLooksVirtual(t *testing.T) { func TestLooksVirtual(t *testing.T) {
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc"} virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc", "podman0", "podman3"}
for _, n := range virtual { for _, n := range virtual {
assert.True(t, looksVirtual(n), "%s should look virtual", n) assert.True(t, looksVirtual(n), "%s should look virtual", n)
} }
+1
View File
@@ -418,6 +418,7 @@ func handleConnection(conn net.Conn) {
conn.Write(capsData) conn.Write(capsData)
conn.Write([]byte("\n")) conn.Write([]byte("\n"))
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), 64*1024*1024) // grow up to 64 MB for large clipboard payloads
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
+20 -11
View File
@@ -103,15 +103,7 @@ func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) deployConfigurations() tea.Cmd { func (m Model) deployConfigurations() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Determine the selected window manager // Determine the selected window manager
var wm deps.WindowManager wm := m.selectedWindowManager()
switch m.selectedWM {
case 0:
wm = deps.WindowManagerNiri
case 1:
wm = deps.WindowManagerHyprland
default:
wm = deps.WindowManagerNiri
}
// Determine the selected terminal // Determine the selected terminal
var terminal deps.Terminal var terminal deps.Terminal
@@ -288,7 +280,8 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
var configs []ExistingConfigInfo var configs []ExistingConfigInfo
if m.selectedWM == 0 { switch m.selectedWindowManager() {
case deps.WindowManagerNiri:
niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl") niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
niriExists := false niriExists := false
if _, err := os.Stat(niriPath); err == nil { if _, err := os.Stat(niriPath); err == nil {
@@ -299,7 +292,23 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
Path: niriPath, Path: niriPath,
Exists: niriExists, Exists: niriExists,
}) })
} else { case deps.WindowManagerMango:
mangoConfPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf")
mangoMainPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf")
mangoPath := mangoConfPath
mangoExists := false
if _, err := os.Stat(mangoConfPath); err == nil {
mangoExists = true
} else if _, err := os.Stat(mangoMainPath); err == nil {
mangoPath = mangoMainPath
mangoExists = true
}
configs = append(configs, ExistingConfigInfo{
ConfigType: "Mango",
Path: mangoPath,
Exists: mangoExists,
})
default:
hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua") hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf") hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandPath := hyprlandLuaPath hyprlandPath := hyprlandLuaPath
+5 -7
View File
@@ -209,12 +209,7 @@ func (m Model) installPackages() tea.Cmd {
} }
// Convert TUI selection to deps enum // Convert TUI selection to deps enum
var wm deps.WindowManager wm := m.selectedWindowManager()
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri
} else {
wm = deps.WindowManagerHyprland
}
installerProgressChan := make(chan distros.InstallProgressMsg, 100) installerProgressChan := make(chan distros.InstallProgressMsg, 100)
@@ -245,8 +240,11 @@ func (m Model) installPackages() tea.Cmd {
} }
if greeterSelected { if greeterSelected {
compositorName := "niri" compositorName := "niri"
if m.selectedWM == 1 { switch m.selectedWindowManager() {
case deps.WindowManagerHyprland:
compositorName = "Hyprland" compositorName = "Hyprland"
case deps.WindowManagerMango:
compositorName = "mango"
} }
m.packageProgressChan <- packageInstallProgressMsg{ m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.92, progress: 0.92,
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -65,7 +66,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.skipGentooUseFlags = !m.skipGentooUseFlags m.skipGentooUseFlags = !m.skipGentooUseFlags
return m, nil return m, nil
case "enter": case "enter":
if m.selectedWM == 1 { if m.selectedWindowManager() == deps.WindowManagerHyprland {
return m, m.checkGCCVersion() return m, m.checkGCCVersion()
} }
return m.enterAuthPhase() return m.enterAuthPhase()
+19 -1
View File
@@ -199,8 +199,21 @@ func (m Model) viewInstallComplete() string {
b.WriteString("\n") b.WriteString("\n")
} }
wm := m.selectedWindowManager()
// mango launches DMS via `exec-once=dms run` (not a systemd session target)
loginHint := "If you do not have a greeter, login with \"niri-session\" or \"Hyprland\""
switch wm {
case deps.WindowManagerNiri:
loginHint = "If you do not have a greeter, login with \"niri-session\""
case deps.WindowManagerHyprland:
loginHint = "If you do not have a greeter, login with \"Hyprland\""
case deps.WindowManagerMango:
loginHint = "If you do not have a greeter, login with \"mango\""
}
b.WriteString("\n") b.WriteString("\n")
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\"") info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + loginHint)
b.WriteString(info) b.WriteString(info)
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -209,8 +222,13 @@ func (m Model) viewInstallComplete() string {
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle)) labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n") b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
if wm == deps.WindowManagerMango {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("qs -p ~/.config/quickshell/dms log") + "\n")
} else {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n") b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n") b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n")
}
if m.osInfo != nil { if m.osInfo != nil {
if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" { if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" {
+27 -10
View File
@@ -10,6 +10,26 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// windowManagerOptions returns the WM enums in selection-list order (debian omits
// Hyprland). selectedWM indexes into this, so all index->WM conversions use it.
func (m Model) windowManagerOptions() []deps.WindowManager {
opts := []deps.WindowManager{deps.WindowManagerNiri}
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
opts = append(opts, deps.WindowManagerHyprland)
}
opts = append(opts, deps.WindowManagerMango)
return opts
}
// selectedWindowManager maps the current selectedWM index to its WM enum.
func (m Model) selectedWindowManager() deps.WindowManager {
opts := m.windowManagerOptions()
if m.selectedWM >= 0 && m.selectedWM < len(opts) {
return opts[m.selectedWM]
}
return deps.WindowManagerNiri
}
func (m Model) viewSelectWindowManager() string { func (m Model) viewSelectWindowManager() string {
var b strings.Builder var b strings.Builder
@@ -34,6 +54,11 @@ func (m Model) viewSelectWindowManager() string {
}{"Hyprland", "Dynamic tiling Wayland compositor."}) }{"Hyprland", "Dynamic tiling Wayland compositor."})
} }
options = append(options, struct {
name string
description string
}{"mango", "dwl-based dynamic tiling Wayland compositor."})
for i, option := range options { for i, option := range options {
if i == m.selectedWM { if i == m.selectedWM {
selected := m.styles.SelectedOption.Render("▶ " + option.name) selected := m.styles.SelectedOption.Render("▶ " + option.name)
@@ -152,10 +177,7 @@ func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
maxWMIndex := 1 maxWMIndex := len(m.windowManagerOptions()) - 1
if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
maxWMIndex = 0
}
switch keyMsg.String() { switch keyMsg.String() {
case "up": case "up":
@@ -190,12 +212,7 @@ func (m Model) detectDependencies() tea.Cmd {
} }
// Convert TUI selection to deps enum // Convert TUI selection to deps enum
var wm deps.WindowManager wm := m.selectedWindowManager()
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri // First option is Niri
} else {
wm = deps.WindowManagerHyprland // Second option is Hyprland
}
var terminal deps.Terminal var terminal deps.Terminal
if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
@@ -44,6 +44,8 @@ type HyprlandRulesParser struct {
dmsIncludePos int dmsIncludePos int
rulesAfterDMS int rulesAfterDMS int
dmsProcessed bool dmsProcessed bool
configFormat string
readOnly bool
requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1 requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1
primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config
@@ -82,10 +84,15 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
} }
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
p.probeRequireWindowrulesLine(mainConfig) p.probeRequireWindowrulesLine(mainConfig)
if ap, err := filepath.Abs(mainConfig); err == nil { if ap, err := filepath.Abs(mainConfig); err == nil {
p.primaryHyprLua = ap p.primaryHyprLua = ap
} }
} else {
p.configFormat = "hyprlang"
p.readOnly = true
} }
if err := p.parseFile(mainConfig); err != nil { if err := p.parseFile(mainConfig); err != nil {
@@ -300,6 +307,8 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
IncludePosition: p.dmsIncludePos, IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount, TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS, RulesAfterDMS: p.rulesAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
} }
switch { switch {
@@ -451,6 +460,9 @@ func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
} }
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error { func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
rules = []windowrules.WindowRule{} rules = []windowrules.WindowRule{}
@@ -472,6 +484,9 @@ func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
} }
func (p *HyprlandWritableProvider) RemoveRule(id string) error { func (p *HyprlandWritableProvider) RemoveRule(id string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
return err return err
@@ -488,6 +503,9 @@ func (p *HyprlandWritableProvider) RemoveRule(id string) error {
} }
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
return err return err
@@ -513,6 +531,29 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
return p.writeDMSRules(newRules) return p.writeDMSRules(newRules)
} }
func (p *HyprlandWritableProvider) ensureWritableConfig() error {
if p.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing window rules")
}
return nil
}
func (p *HyprlandWritableProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(p.configDir)
if err != nil {
expanded = p.configDir
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
@@ -758,11 +799,7 @@ func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath st
Actions: *acts, Actions: *acts,
} }
if wr.ID == "" { if wr.ID == "" {
if wr.MatchCriteria.AppID != "" { wr.ID = fmt.Sprintf("dms_rule_%d", len(rules))
wr.ID = wr.MatchCriteria.AppID
} else {
wr.ID = wr.MatchCriteria.Title
}
} }
rules = append(rules, wr) rules = append(rules, wr)
} }
@@ -188,6 +188,27 @@ func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
} }
} }
func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("windowrulev2 = float, class:^(kitty)$\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandWritableProvider(tmpDir)
rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
rule.Actions.OpenFloating = boolPtr(true)
err := provider.SetRule(rule)
if err == nil {
t.Fatal("expected SetRule to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "windowrules.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua windowrules file to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandRemoveRule(t *testing.T) { func TestHyprlandRemoveRule(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir) provider := NewHyprlandWritableProvider(tmpDir)
@@ -0,0 +1,378 @@
package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
// Mango window rules are flat `windowrule=key:value,...` lines. DMS-managed rules
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
// `# @id=<id> @name=<name>` comment so they round-trip.
type MangoWindowRule struct {
Source string
Fields map[string]string
}
var mangoWindowRuleRegex = regexp.MustCompile(`^windowrule\s*=\s*(.+)$`)
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
func parseMangoWindowRuleLine(value string) map[string]string {
fields := map[string]string{}
for _, pair := range strings.Split(value, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
colon := strings.Index(pair, ":")
if colon < 0 {
continue
}
key := strings.TrimSpace(pair[:colon])
val := strings.TrimSpace(pair[colon+1:])
if key != "" {
fields[key] = val
}
}
return fields
}
// mangoConfigPath returns the main mango config (config.conf or mango.conf).
func mangoConfigPath(configDir string) string {
candidates := []string{
filepath.Join(configDir, "config.conf"),
filepath.Join(configDir, "mango.conf"),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return candidates[0]
}
func mangoOverridePath(configDir string) string {
return filepath.Join(configDir, "dms", "windowrules.conf")
}
// parseMangoRulesFile reads a config file and returns its windowrule= lines.
func parseMangoRulesFile(path, source string) []MangoWindowRule {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var rules []MangoWindowRule
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
rules = append(rules, MangoWindowRule{Source: source, Fields: parseMangoWindowRuleLine(m[1])})
}
}
return rules
}
type MangoRulesParseResult struct {
Rules []MangoWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseMangoWindowRules(configDir string) (*MangoRulesParseResult, error) {
mainPath := mangoConfigPath(configDir)
overridePath := mangoOverridePath(configDir)
var rules []MangoWindowRule
rules = append(rules, parseMangoRulesFile(mainPath, "config.conf")...)
rules = append(rules, parseMangoRulesFile(overridePath, "dms/windowrules.conf")...)
included := mangoDMSRulesIncluded(mainPath)
return &MangoRulesParseResult{
Rules: rules,
DMSRulesIncluded: included,
DMSStatus: &windowrules.DMSRulesStatus{
Exists: fileExists(overridePath),
Included: included,
Effective: included,
ConfigFormat: "conf",
StatusMessage: mangoIncludeMessage(included),
},
}, nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func mangoDMSRulesIncluded(mainPath string) bool {
data, err := os.ReadFile(mainPath)
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") && strings.Contains(trimmed, "dms/windowrules.conf") {
return true
}
}
return false
}
func mangoIncludeMessage(included bool) string {
if included {
return "DMS window rules are sourced from config.conf"
}
return "Add `source=./dms/windowrules.conf` to config.conf to apply DMS window rules"
}
func mangoBoolField(fields map[string]string, key string) *bool {
v, ok := fields[key]
if !ok {
return nil
}
b := v == "1" || strings.EqualFold(v, "true")
return &b
}
func mangoBoolStr(b *bool) string {
if b != nil && *b {
return "1"
}
return "0"
}
func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(mangoRules))
for i, mr := range mangoRules {
f := mr.Fields
actions := windowrules.Actions{
OpenFloating: mangoBoolField(f, "isfloating"),
OpenFullscreen: mangoBoolField(f, "isfullscreen"),
NoBlur: mangoBoolField(f, "noblur"),
NoBorder: mangoBoolField(f, "isnoborder"),
NoShadow: mangoBoolField(f, "isnoshadow"),
NoRounding: mangoBoolField(f, "isnoradius"),
NoAnim: mangoBoolField(f, "isnoanimation"),
}
if tags, ok := f["tags"]; ok {
actions.Workspace = tags
}
if mon, ok := f["monitor"]; ok {
actions.Monitor = mon
}
if w, ok := f["width"]; ok {
if h, ok2 := f["height"]; ok2 {
actions.Size = w + "x" + h
}
}
result = append(result, windowrules.WindowRule{
ID: fmt.Sprintf("rule_%d", i),
Enabled: true,
Source: mr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: f["appid"],
Title: f["title"],
},
Actions: actions,
})
}
return result
}
// formatMangoRule serializes a shared WindowRule into a mango windowrule= line.
func formatMangoRule(rule windowrules.WindowRule) string {
var parts []string
add := func(k, v string) {
if v != "" {
parts = append(parts, k+":"+v)
}
}
add("appid", rule.MatchCriteria.AppID)
add("title", rule.MatchCriteria.Title)
add("tags", rule.Actions.Workspace)
add("monitor", rule.Actions.Monitor)
if rule.Actions.Size != "" {
if w, h, ok := splitSize(rule.Actions.Size); ok {
add("width", w)
add("height", h)
}
}
addBool := func(k string, b *bool) {
if b != nil {
parts = append(parts, k+":"+mangoBoolStr(b))
}
}
addBool("isfloating", rule.Actions.OpenFloating)
addBool("isfullscreen", rule.Actions.OpenFullscreen)
addBool("noblur", rule.Actions.NoBlur)
addBool("isnoborder", rule.Actions.NoBorder)
addBool("isnoshadow", rule.Actions.NoShadow)
addBool("isnoradius", rule.Actions.NoRounding)
addBool("isnoanimation", rule.Actions.NoAnim)
return "windowrule=" + strings.Join(parts, ",")
}
func splitSize(size string) (w, h string, ok bool) {
for _, sep := range []string{"x", "X", " "} {
if parts := strings.Split(size, sep); len(parts) == 2 {
w = strings.TrimSpace(parts[0])
h = strings.TrimSpace(parts[1])
if _, err := strconv.ParseFloat(w, 64); err == nil {
return w, h, true
}
}
}
return "", "", false
}
type MangoWritableProvider struct {
configDir string
}
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
return &MangoWritableProvider{configDir: configDir}
}
func (p *MangoWritableProvider) Name() string { return "mango" }
func (p *MangoWritableProvider) GetOverridePath() string {
return mangoOverridePath(p.configDir)
}
func (p *MangoWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseMangoWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Mango Window Rules",
Provider: "mango",
Rules: ConvertMangoRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *MangoWritableProvider) SetRule(rule windowrules.WindowRule) error {
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
}
found := false
for i, r := range rules {
if r.ID == rule.ID {
rules[i] = rule
found = true
break
}
}
if !found {
rules = append(rules, rule)
}
return p.writeDMSRules(rules)
}
func (p *MangoWritableProvider) RemoveRule(id string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
newRules := make([]windowrules.WindowRule, 0, len(rules))
for _, r := range rules {
if r.ID != id {
newRules = append(newRules, r)
}
}
return p.writeDMSRules(newRules)
}
func (p *MangoWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule, len(rules))
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
// LoadDMSRules parses only the DMS override file, preserving @id/@name metadata.
func (p *MangoWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
data, err := os.ReadFile(p.GetOverridePath())
if err != nil {
if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
}
return nil, err
}
var rules []windowrules.WindowRule
var curID, curName string
idx := 0
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoMetaCommentRegex.FindStringSubmatch(trimmed); m != nil {
curID = m[1]
curName = strings.TrimSpace(m[2])
continue
}
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
converted := ConvertMangoRulesToWindowRules([]MangoWindowRule{{Source: "dms/windowrules.conf", Fields: parseMangoWindowRuleLine(m[1])}})
wr := converted[0]
if curID != "" {
wr.ID = curID
} else {
wr.ID = fmt.Sprintf("rule_%d", idx)
}
wr.Name = curName
rules = append(rules, wr)
curID, curName = "", ""
idx++
}
}
return rules, nil
}
func (p *MangoWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
overridePath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return err
}
var sb strings.Builder
sb.WriteString("# Auto-generated by DMS - DMS-managed mango window rules\n\n")
for i, r := range rules {
id := r.ID
if id == "" {
id = fmt.Sprintf("rule_%d", i)
}
fmt.Fprintf(&sb, "# @id=%s @name=%s\n", id, r.Name)
sb.WriteString(formatMangoRule(r))
sb.WriteString("\n\n")
}
return os.WriteFile(overridePath, []byte(sb.String()), 0o644)
}
@@ -0,0 +1,116 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
func TestParseMangoWindowRuleLine(t *testing.T) {
fields := parseMangoWindowRuleLine("appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1")
if fields["appid"] != "firefox" {
t.Errorf("appid = %q, want firefox", fields["appid"])
}
if fields["title"] != "Gmail" {
t.Errorf("title = %q, want Gmail", fields["title"])
}
if fields["isfloating"] != "1" {
t.Errorf("isfloating = %q, want 1", fields["isfloating"])
}
if fields["tags"] != "2" {
t.Errorf("tags = %q, want 2", fields["tags"])
}
if fields["monitor"] != "HDMI-A-1" {
t.Errorf("monitor = %q, want HDMI-A-1", fields["monitor"])
}
}
func TestConvertMangoRulesToWindowRules(t *testing.T) {
mangoRules := []MangoWindowRule{
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")},
}
rules := ConvertMangoRulesToWindowRules(mangoRules)
if len(rules) != 1 {
t.Fatalf("got %d rules, want 1", len(rules))
}
r := rules[0]
if r.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", r.MatchCriteria.AppID)
}
if r.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", r.Actions.Workspace)
}
if r.Actions.OpenFloating == nil || !*r.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", r.Actions.OpenFloating)
}
if r.Actions.NoBlur == nil || !*r.Actions.NoBlur {
t.Errorf("NoBlur = %v, want true", r.Actions.NoBlur)
}
}
func TestMangoSetAndLoadRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
provider := NewMangoWritableProvider(tmpDir)
floating := true
rule := windowrules.WindowRule{
ID: "rule_test",
Name: "Float Discord",
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: "discord",
},
Actions: windowrules.Actions{
OpenFloating: &floating,
Workspace: "9",
Size: "1000x900",
},
}
if err := provider.SetRule(rule); err != nil {
t.Fatalf("SetRule: %v", err)
}
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
if _, err := os.Stat(expectedPath); err != nil {
t.Fatalf("override file not written: %v", err)
}
loaded, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules: %v", err)
}
if len(loaded) != 1 {
t.Fatalf("got %d rules, want 1", len(loaded))
}
got := loaded[0]
if got.ID != "rule_test" {
t.Errorf("ID = %q, want rule_test", got.ID)
}
if got.Name != "Float Discord" {
t.Errorf("Name = %q, want 'Float Discord'", got.Name)
}
if got.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", got.MatchCriteria.AppID)
}
if got.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
}
if got.Actions.Size != "1000x900" {
t.Errorf("Size = %q, want 1000x900", got.Actions.Size)
}
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
}
// Remove and confirm empty.
if err := provider.RemoveRule("rule_test"); err != nil {
t.Fatalf("RemoveRule: %v", err)
}
loaded, _ = provider.LoadDMSRules()
if len(loaded) != 0 {
t.Errorf("after remove got %d rules, want 0", len(loaded))
}
}
@@ -14,6 +14,18 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
) )
type NiriMatch struct {
AppID string
Title string
IsFloating *bool
IsActive *bool
IsFocused *bool
IsActiveInColumn *bool
IsWindowCastTarget *bool
IsUrgent *bool
AtStartup *bool
}
type NiriWindowRule struct { type NiriWindowRule struct {
MatchAppID string MatchAppID string
MatchTitle string MatchTitle string
@@ -24,6 +36,7 @@ type NiriWindowRule struct {
MatchIsWindowCastTarget *bool MatchIsWindowCastTarget *bool
MatchIsUrgent *bool MatchIsUrgent *bool
MatchAtStartup *bool MatchAtStartup *bool
Matches []NiriMatch
Opacity *float64 Opacity *float64
OpenFloating *bool OpenFloating *bool
OpenMaximized *bool OpenMaximized *bool
@@ -50,6 +63,13 @@ type NiriWindowRule struct {
FocusRingOff *bool FocusRingOff *bool
BorderOff *bool BorderOff *bool
DrawBorderWithBg *bool DrawBorderWithBg *bool
BgBlur *bool
BgXray *bool
BgNoise *float64
BgSaturation *float64
DefaultFloatingX *int
DefaultFloatingY *int
DefaultFloatingRelative string
Source string Source string
} }
@@ -191,7 +211,7 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
switch childName { switch childName {
case "match": case "match":
p.parseMatchNode(child, &rule) rule.Matches = append(rule.Matches, p.parseMatchNode(child))
case "opacity": case "opacity":
if len(child.Arguments) > 0 { if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue() val := child.Arguments[0].ResolvedValue()
@@ -297,9 +317,26 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
case "draw-border-with-background": case "draw-border-with-background":
b := p.parseBoolArg(child) b := p.parseBoolArg(child)
rule.DrawBorderWithBg = &b rule.DrawBorderWithBg = &b
case "background-effect":
p.parseBackgroundEffectNode(child, &rule)
case "default-floating-position":
p.parseFloatingPositionNode(child, &rule)
} }
} }
if len(rule.Matches) > 0 {
first := rule.Matches[0]
rule.MatchAppID = first.AppID
rule.MatchTitle = first.Title
rule.MatchIsFloating = first.IsFloating
rule.MatchIsActive = first.IsActive
rule.MatchIsFocused = first.IsFocused
rule.MatchIsActiveInColumn = first.IsActiveInColumn
rule.MatchIsWindowCastTarget = first.IsWindowCastTarget
rule.MatchIsUrgent = first.IsUrgent
rule.MatchAtStartup = first.AtStartup
}
p.rules = append(p.rules, rule) p.rules = append(p.rules, rule)
} }
@@ -326,45 +363,47 @@ func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
return "" return ""
} }
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) { func (p *NiriRulesParser) parseMatchNode(node *document.Node) NiriMatch {
m := NiriMatch{}
if node.Properties == nil { if node.Properties == nil {
return return m
} }
if val, ok := node.Properties.Get("app-id"); ok { if val, ok := node.Properties.Get("app-id"); ok {
rule.MatchAppID = val.ValueString() m.AppID = val.ValueString()
} }
if val, ok := node.Properties.Get("title"); ok { if val, ok := node.Properties.Get("title"); ok {
rule.MatchTitle = val.ValueString() m.Title = val.ValueString()
} }
if val, ok := node.Properties.Get("is-floating"); ok { if val, ok := node.Properties.Get("is-floating"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsFloating = &b m.IsFloating = &b
} }
if val, ok := node.Properties.Get("is-active"); ok { if val, ok := node.Properties.Get("is-active"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsActive = &b m.IsActive = &b
} }
if val, ok := node.Properties.Get("is-focused"); ok { if val, ok := node.Properties.Get("is-focused"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsFocused = &b m.IsFocused = &b
} }
if val, ok := node.Properties.Get("is-active-in-column"); ok { if val, ok := node.Properties.Get("is-active-in-column"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsActiveInColumn = &b m.IsActiveInColumn = &b
} }
if val, ok := node.Properties.Get("is-window-cast-target"); ok { if val, ok := node.Properties.Get("is-window-cast-target"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsWindowCastTarget = &b m.IsWindowCastTarget = &b
} }
if val, ok := node.Properties.Get("is-urgent"); ok { if val, ok := node.Properties.Get("is-urgent"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsUrgent = &b m.IsUrgent = &b
} }
if val, ok := node.Properties.Get("at-startup"); ok { if val, ok := node.Properties.Get("at-startup"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchAtStartup = &b m.AtStartup = &b
} }
return m
} }
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) { func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
@@ -385,6 +424,64 @@ func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowR
} }
} }
func (p *NiriRulesParser) parseBackgroundEffectNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "blur":
b := p.parseBoolArg(child)
rule.BgBlur = &b
case "xray":
b := p.parseBoolArg(child)
rule.BgXray = &b
case "noise":
if f, ok := p.parseFloatArg(child); ok {
rule.BgNoise = &f
}
case "saturation":
if f, ok := p.parseFloatArg(child); ok {
rule.BgSaturation = &f
}
}
}
}
func (p *NiriRulesParser) parseFloatingPositionNode(node *document.Node, rule *NiriWindowRule) {
if node.Properties == nil {
return
}
if val, ok := node.Properties.Get("x"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
rule.DefaultFloatingX = &n
}
}
if val, ok := node.Properties.Get("y"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
rule.DefaultFloatingY = &n
}
}
if val, ok := node.Properties.Get("relative-to"); ok {
rule.DefaultFloatingRelative = val.ValueString()
}
}
func (p *NiriRulesParser) parseFloatArg(node *document.Node) (float64, bool) {
if len(node.Arguments) == 0 {
return 0, false
}
val := node.Arguments[0].ResolvedValue()
switch v := val.(type) {
case float64:
return v, true
case int64:
return float64(v), true
}
return 0, false
}
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) { func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil { if node.Children == nil {
return return
@@ -461,6 +558,27 @@ func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
}, nil }, nil
} }
func convertNiriMatches(matches []NiriMatch) []windowrules.MatchCriteria {
if len(matches) == 0 {
return nil
}
result := make([]windowrules.MatchCriteria, 0, len(matches))
for _, m := range matches {
result = append(result, windowrules.MatchCriteria{
AppID: m.AppID,
Title: m.Title,
IsFloating: m.IsFloating,
IsActive: m.IsActive,
IsFocused: m.IsFocused,
IsActiveInColumn: m.IsActiveInColumn,
IsWindowCastTarget: m.IsWindowCastTarget,
IsUrgent: m.IsUrgent,
AtStartup: m.AtStartup,
})
}
return result
}
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule { func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(niriRules)) result := make([]windowrules.WindowRule, 0, len(niriRules))
for i, nr := range niriRules { for i, nr := range niriRules {
@@ -479,6 +597,7 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
IsUrgent: nr.MatchIsUrgent, IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup, AtStartup: nr.MatchAtStartup,
}, },
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{ Actions: windowrules.Actions{
Opacity: nr.Opacity, Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating, OpenFloating: nr.OpenFloating,
@@ -506,6 +625,13 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
FocusRingOff: nr.FocusRingOff, FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff, BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg, DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
DefaultFloatingX: nr.DefaultFloatingX,
DefaultFloatingY: nr.DefaultFloatingY,
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
}, },
} }
result = append(result, wr) result = append(result, wr)
@@ -684,6 +810,7 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
IsUrgent: nr.MatchIsUrgent, IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup, AtStartup: nr.MatchAtStartup,
}, },
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{ Actions: windowrules.Actions{
Opacity: nr.Opacity, Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating, OpenFloating: nr.OpenFloating,
@@ -711,6 +838,13 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
FocusRingOff: nr.FocusRingOff, FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff, BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg, DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
DefaultFloatingX: nr.DefaultFloatingX,
DefaultFloatingY: nr.DefaultFloatingY,
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
}, },
} }
@@ -740,15 +874,7 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644) return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
} }
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string { func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {")
m := rule.MatchCriteria
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
m.IsUrgent != nil || m.AtStartup != nil {
var matchProps []string var matchProps []string
if m.AppID != "" { if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID)) matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
@@ -777,7 +903,25 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
if m.AtStartup != nil { if m.AtStartup != nil {
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup)) matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
} }
lines = append(lines, " match "+strings.Join(matchProps, " ")) if len(matchProps) == 0 {
return "", false
}
return " match " + strings.Join(matchProps, " "), true
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {")
matches := rule.Matches
if len(matches) == 0 {
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
}
for _, m := range matches {
if line, ok := formatNiriMatchLine(m); ok {
lines = append(lines, line)
}
} }
a := rule.Actions a := rule.Actions
@@ -858,10 +1002,39 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg)) lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
} }
if a.BackgroundBlur != nil || a.BackgroundXray != nil || a.BackgroundNoise != nil || a.BackgroundSaturation != nil {
lines = append(lines, " background-effect {")
if a.BackgroundBlur != nil {
lines = append(lines, fmt.Sprintf(" blur %t", *a.BackgroundBlur))
}
if a.BackgroundXray != nil {
lines = append(lines, fmt.Sprintf(" xray %t", *a.BackgroundXray))
}
if a.BackgroundNoise != nil {
lines = append(lines, fmt.Sprintf(" noise %s", formatFloat(*a.BackgroundNoise)))
}
if a.BackgroundSaturation != nil {
lines = append(lines, fmt.Sprintf(" saturation %s", formatFloat(*a.BackgroundSaturation)))
}
lines = append(lines, " }")
}
if a.DefaultFloatingX != nil && a.DefaultFloatingY != nil {
line := fmt.Sprintf(" default-floating-position x=%d y=%d", *a.DefaultFloatingX, *a.DefaultFloatingY)
if a.DefaultFloatingRelativeTo != "" {
line += fmt.Sprintf(" relative-to=%q", a.DefaultFloatingRelativeTo)
}
lines = append(lines, line)
}
lines = append(lines, "}") lines = append(lines, "}")
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func formatSizeProperty(name, value string) string { func formatSizeProperty(name, value string) string {
parts := strings.SplitN(value, " ", 2) parts := strings.SplitN(value, " ", 2)
if len(parts) == 2 { if len(parts) == 2 {
+11
View File
@@ -43,6 +43,14 @@ type Actions struct {
FocusRingOff *bool `json:"focusRingOff,omitempty"` FocusRingOff *bool `json:"focusRingOff,omitempty"`
BorderOff *bool `json:"borderOff,omitempty"` BorderOff *bool `json:"borderOff,omitempty"`
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"` DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
BackgroundBlur *bool `json:"backgroundBlur,omitempty"`
BackgroundXray *bool `json:"backgroundXray,omitempty"`
BackgroundNoise *float64 `json:"backgroundNoise,omitempty"`
BackgroundSaturation *float64 `json:"backgroundSaturation,omitempty"`
DefaultFloatingX *int `json:"defaultFloatingX,omitempty"`
DefaultFloatingY *int `json:"defaultFloatingY,omitempty"`
DefaultFloatingRelativeTo string `json:"defaultFloatingRelativeTo,omitempty"`
Size string `json:"size,omitempty"` Size string `json:"size,omitempty"`
Move string `json:"move,omitempty"` Move string `json:"move,omitempty"`
Monitor string `json:"monitor,omitempty"` Monitor string `json:"monitor,omitempty"`
@@ -66,6 +74,7 @@ type WindowRule struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"` MatchCriteria MatchCriteria `json:"matchCriteria"`
Matches []MatchCriteria `json:"matches,omitempty"`
Actions Actions `json:"actions"` Actions Actions `json:"actions"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
} }
@@ -79,6 +88,8 @@ type DMSRulesStatus struct {
Effective bool `json:"effective"` Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"` OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"` StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
type RuleSet struct { type RuleSet struct {
+7
View File
@@ -48,6 +48,13 @@ fragments.
keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in
DMS Settings. DMS Settings.
Stock configs include a 3-finger horizontal touchpad gesture for workspace
switching (`hl.gesture` in `dms/binds.lua`) and basic touchpad settings
(`tap_to_click`, `natural_scroll` in `hyprland.lua`). To customize or disable
gestures, add your own `hl.gesture(...)` lines to `dms/binds-user.lua`, or unset
a stock gesture with `action = "unset"` matching the original fingers,
direction, and modifiers.
Most other existing non-empty Lua fragments are preserved. Most other existing non-empty Lua fragments are preserved.
## Legacy Config Migration ## Legacy Config Migration
+111
View File
@@ -282,6 +282,53 @@ dms ipc call inhibit toggle
dms ipc call inhibit enable dms ipc call inhibit enable
``` ```
## Target: `powerprofile`
Power profile control via `power-profiles-daemon`. Changes stay in sync with DMS UI and trigger the power profile OSD when enabled.
Requires `power-profiles-daemon` to be installed and running. Works on all compositors.
### Functions
**`open`**
- Show the power profile picker modal
- Returns: Success confirmation or error if daemon unavailable
**`close`**
- Close the power profile picker modal
- Returns: Success confirmation
**`toggle`**
- Toggle power profile picker modal visibility
- Returns: Success confirmation or error if daemon unavailable
**`list`**
- List available profile slugs, one per line
- Returns: `power-saver`, `balanced`, and `performance` when supported
**`status`**
- Get the currently active profile slug
- Returns: `power-saver`, `balanced`, `performance`, or error if daemon unavailable
**`set <profile>`**
- Set the active power profile
- Parameters: Profile slug or alias — `power-saver` (`powersaver`, `saver`, `0`), `balanced` (`1`), `performance` (`2`)
- Returns: Success confirmation or error if profile unknown, unsupported, or write failed
**`cycle`**
- Cycle to the next available profile in order: power-saver → balanced → performance → power-saver
- Returns: Success confirmation or error if daemon unavailable or write failed
### Examples
```bash
dms ipc call powerprofile status
dms ipc call powerprofile list
dms ipc call powerprofile cycle
dms ipc call powerprofile set balanced
dms ipc call powerprofile set performance
dms ipc call powerprofile toggle
```
## Target: `wallpaper` ## Target: `wallpaper`
Wallpaper management and retrieval with support for per-monitor configurations. Wallpaper management and retrieval with support for per-monitor configurations.
@@ -485,6 +532,54 @@ dms ipc call systemupdater close
dms ipc call systemupdater updatestatus dms ipc call systemupdater updatestatus
``` ```
## Target: `defaultApp`
Launch applications configured in Settings > Default Apps.
### Functions
**`browser`**
- Launch the configured default web browser
- Returns: Launch request confirmation
**`fileManager`**
- Launch the configured default file manager
- Returns: Launch request confirmation
**`textEditor`**
- Launch the configured default text editor
- Returns: Launch request confirmation
**`pdfReader`**
- Launch the configured default PDF reader
- Returns: Launch request confirmation
**`imageViewer`**
- Launch the configured default image viewer
- Returns: Launch request confirmation
**`videoPlayer`**
- Launch the configured default video player
- Returns: Launch request confirmation
**`musicPlayer`**
- Launch the configured default music player
- Returns: Launch request confirmation
**`mail`**
- Launch the configured default mail client
- Returns: Launch request confirmation
**`calendar`**
- Launch the configured default calendar application
- Returns: Launch request confirmation
### Examples
```bash
dms ipc call defaultApp browser
dms ipc call defaultApp fileManager
```
## Modal Controls ## Modal Controls
These targets control various modal windows and overlays. These targets control various modal windows and overlays.
@@ -543,6 +638,18 @@ Power menu modal control for system power actions.
- `close` - Hide power menu modal - `close` - Hide power menu modal
- `toggle` - Toggle power menu modal visibility - `toggle` - Toggle power menu modal visibility
### Target: `powerprofile`
Power profile picker modal and profile control via `power-profiles-daemon`.
**Functions:**
- `open` - Show power profile picker modal
- `close` - Hide power profile picker modal
- `toggle` - Toggle power profile picker modal visibility
- `list` - List available profile slugs
- `status` - Get current profile slug
- `set <profile>` - Set profile by slug or alias (`power-saver`, `balanced`, `performance`)
- `cycle` - Cycle to the next available profile
### Target: `control-center` ### Target: `control-center`
Control Center popout containing network, bluetooth, audio, power, and other quick settings. Control Center popout containing network, bluetooth, audio, power, and other quick settings.
@@ -673,6 +780,10 @@ dms ipc call processlist toggle
# Show power menu # Show power menu
dms ipc call powermenu toggle dms ipc call powermenu toggle
# Cycle or set power profile (requires power-profiles-daemon)
dms ipc call powerprofile cycle
dms ipc call powerprofile toggle
# Open notepad # Open notepad
dms ipc call notepad toggle dms ipc call notepad toggle
+4
View File
@@ -11,6 +11,10 @@ Singleton {
readonly property int durMed: 450 readonly property int durMed: 450
readonly property int durLong: 600 readonly property int durLong: 600
// Navigation feedback stays responsive even when ambient shell motion is slow.
readonly property int settingsNavigationStateDuration: 180
readonly property int settingsNavigationRippleDuration: 200
readonly property int slidePx: 80 readonly property int slidePx: 80
readonly property var emphasized: [0.05, 0.00, 0.133333, 0.06, 0.166667, 0.40, 0.208333, 0.82, 0.25, 1.00, 1.00, 1.00] readonly property var emphasized: [0.05, 0.00, 0.133333, 0.06, 0.166667, 0.40, 0.208333, 0.82, 0.25, 1.00, 1.00, 1.00]
+111
View File
@@ -0,0 +1,111 @@
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
required property var modalHandle
required property string claimPrefix
property string surfaceKind: "modal"
property string screenName: ""
property bool enabled: false
property bool active: false
property bool presented: false
property bool dockBlocked: false
property string dockSide: ""
property alias claimId: lease.claimId
property alias claimedScreenName: lease.claimedScreenName
signal recoveryRequested
visible: false
function _isCurrentModal(name) {
return !!name && ModalManager.isCurrentModal(modalHandle, name);
}
ConnectedSurfaceLease {
id: lease
claimPrefix: root.claimPrefix
screenName: root.screenName
enabled: root.enabled
active: root.active
presented: root.presented
dockBlocked: root.dockBlocked
dockSide: root.dockSide
isCurrentOwner: function(name) {
return root._isCurrentModal(name);
}
hasOwner: function(name, ownerId) {
return ConnectedModeState.hasModalOwner(name, ownerId);
}
statePresent: function(name, ownerId) {
return ConnectedModeState.hasModalOwner(name, ownerId) && ConnectedModeState.hasSurfaceDescriptor(name, root.surfaceKind, ownerId);
}
claimState: function(name, state, ownerId) {
return ConnectedModeState.claimModalState(name, state, ownerId);
}
ensureState: function(name, state, ownerId) {
return ConnectedModeState.ensureModalState(name, state, ownerId);
}
releaseState: function(name, ownerId) {
return ConnectedModeState.clearModalState(name, ownerId);
}
updateAnimationState: function(name, ownerId, animX, animY) {
return ConnectedModeState.setModalAnim(name, animX, animY, ownerId);
}
updateBodyState: function(name, ownerId, bodyX, bodyY, bodyW, bodyH) {
return ConnectedModeState.setModalBody(name, bodyX, bodyY, bodyW, bodyH, ownerId);
}
requestDockRetract: function(ownerId, name, side) {
return ConnectedModeState.requestDockRetract(ownerId, name, side);
}
releaseDockRetract: function(ownerId) {
return ConnectedModeState.releaseDockRetract(ownerId);
}
onRecoveryRequested: root.recoveryRequested()
}
function publish(state) {
return lease.publish(Object.assign({}, state, {
"kind": root.surfaceKind,
"screenName": root.screenName,
"presented": root.presented,
"dockRetractSide": root.dockBlocked ? root.dockSide : ""
}), false);
}
function updateAnim(animX, animY) {
return lease.updateAnim(animX, animY);
}
function updateBody(bodyX, bodyY, bodyW, bodyH) {
return lease.updateBody(bodyX, bodyY, bodyW, bodyH);
}
function release() {
return lease.release();
}
Connections {
target: ModalManager
function onModalChanged() {
lease.requestRecovery();
}
}
Connections {
target: ConnectedModeState
function onModalOwnersChanged() {
lease.checkOwnershipRecovery();
}
function onModalStatesChanged() {
lease.checkStateRecovery();
}
function onSurfaceDescriptorsChanged() {
lease.checkStateRecovery();
}
}
}
+258 -18
View File
@@ -3,10 +3,141 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import "ConnectedSurfaceDescriptor.js" as SurfaceDescriptor
Singleton { Singleton {
id: root id: root
property var surfaceDescriptors: ({})
function _surfaceSlot(kind) {
return SurfaceDescriptor.slotForKind(kind);
}
function surfaceDescriptor(screenName, kind) {
const slot = _surfaceSlot(kind);
const screenDescriptors = screenName ? surfaceDescriptors[screenName] : null;
const descriptor = screenDescriptors && screenDescriptors[slot] ? screenDescriptors[slot] : SurfaceDescriptor.empty(kind, screenName);
let bodyRect = descriptor.bodyRect;
let animationOffset = descriptor.animationOffset;
if (slot === "popout" && popoutScreen === screenName) {
bodyRect = {
"x": popoutBodyX,
"y": popoutBodyY,
"width": popoutBodyW,
"height": popoutBodyH
};
animationOffset = {
"x": popoutAnimX,
"y": popoutAnimY
};
} else if (slot === "modal" && modalStates[screenName]) {
const modal = modalStates[screenName];
bodyRect = {
"x": modal.bodyX,
"y": modal.bodyY,
"width": modal.bodyW,
"height": modal.bodyH
};
animationOffset = {
"x": modal.animX,
"y": modal.animY
};
} else if (slot === "dock" && dockStates[screenName]) {
const dock = dockStates[screenName];
const slide = dockSlides[screenName] || {
"x": dock.slideX,
"y": dock.slideY
};
bodyRect = {
"x": dock.bodyX,
"y": dock.bodyY,
"width": dock.bodyW,
"height": dock.bodyH
};
animationOffset = {
"x": slide.x,
"y": slide.y
};
} else if (slot === "notification" && notificationStates[screenName]) {
const notification = notificationStates[screenName];
bodyRect = {
"x": notification.bodyX,
"y": notification.bodyY,
"width": notification.bodyW,
"height": notification.bodyH
};
}
return SurfaceDescriptor.normalize({
"bodyRect": bodyRect,
"animationOffset": animationOffset
}, descriptor);
}
function legacySurfaceState(screenName, kind) {
return SurfaceDescriptor.toLegacyState(surfaceDescriptor(screenName, kind));
}
function hasSurfaceDescriptor(screenName, kind, ownerId) {
const descriptor = surfaceDescriptor(screenName, kind);
return descriptor.phase !== "hidden" && (!ownerId || descriptor.ownerId === ownerId);
}
function _setSurfaceDescriptor(screenName, slotKind, state, ownerId) {
if (!screenName || !state)
return false;
const slot = _surfaceSlot(slotKind);
const currentScreen = surfaceDescriptors[screenName] || {};
const previous = currentScreen[slot] || SurfaceDescriptor.empty(state.kind || slotKind, screenName);
let normalized = SurfaceDescriptor.normalize(Object.assign({}, state, {
"ownerId": ownerId !== undefined ? ownerId : previous.ownerId,
"screenName": screenName,
"revision": previous.revision
}), previous);
if (SurfaceDescriptor.same(previous, normalized))
return true;
normalized = SurfaceDescriptor.withRevision(normalized, previous.revision + 1);
const nextScreen = _cloneDict(currentScreen);
nextScreen[slot] = normalized;
const next = _cloneDict(surfaceDescriptors);
next[screenName] = nextScreen;
surfaceDescriptors = next;
return true;
}
function _clearSurfaceDescriptor(screenName, kind, ownerId) {
if (!screenName)
return false;
const slot = _surfaceSlot(kind);
const currentScreen = surfaceDescriptors[screenName];
const current = currentScreen ? currentScreen[slot] : null;
if (!current || (ownerId && current.ownerId !== ownerId))
return false;
const nextScreen = _cloneDict(currentScreen);
delete nextScreen[slot];
const next = _cloneDict(surfaceDescriptors);
if (Object.keys(nextScreen).length > 0)
next[screenName] = nextScreen;
else
delete next[screenName];
surfaceDescriptors = next;
return true;
}
function _setSurfaceAnimation(screenName, kind, ownerId, x, y) {
const current = surfaceDescriptor(screenName, kind);
if (current.phase === "hidden" || (ownerId && current.ownerId !== ownerId))
return false;
return true;
}
function _setSurfaceBody(screenName, kind, ownerId, x, y, width, height) {
const current = surfaceDescriptor(screenName, kind);
if (current.phase === "hidden" || (ownerId && current.ownerId !== ownerId))
return false;
return true;
}
readonly property var emptyDockState: ({ readonly property var emptyDockState: ({
"reveal": false, "reveal": false,
"barSide": "bottom", "barSide": "bottom",
@@ -38,6 +169,10 @@ Singleton {
// Dock slide offsets hot-path updates separated from full geometry state // Dock slide offsets hot-path updates separated from full geometry state
property var dockSlides: ({}) property var dockSlides: ({})
// Surfaces are keyed by screen.name. FrameWindow watches to refresh connected chrome
// after claim/release boundaries without tracking each animation frame
property var surfaceRevisions: ({})
function _cloneDict(src) { function _cloneDict(src) {
const next = {}; const next = {};
for (const k in src) for (const k in src)
@@ -45,16 +180,33 @@ Singleton {
return next; return next;
} }
function _bumpSurfaceRevision(screenName) {
if (!screenName)
return;
const next = _cloneDict(surfaceRevisions);
next[screenName] = Number(next[screenName] || 0) + 1;
surfaceRevisions = next;
}
function hasPopoutOwner(claimId) { function hasPopoutOwner(claimId) {
return !!claimId && popoutOwnerId === claimId; return !!claimId && popoutOwnerId === claimId;
} }
function claimPopout(claimId, state) { function claimPopout(claimId, state) {
if (!claimId) if (!claimId || !state)
return false; return false;
const previousScreen = popoutScreen;
popoutOwnerId = claimId; popoutOwnerId = claimId;
return updatePopout(claimId, state); const ok = updatePopout(claimId, state);
if (ok) {
if (previousScreen && previousScreen !== popoutScreen) {
_clearSurfaceDescriptor(previousScreen, "popout");
_bumpSurfaceRevision(previousScreen);
}
_bumpSurfaceRevision(popoutScreen);
}
return ok;
} }
function updatePopout(claimId, state) { function updatePopout(claimId, state) {
@@ -84,6 +236,21 @@ Singleton {
if (state.omitEndConnector !== undefined) if (state.omitEndConnector !== undefined)
popoutOmitEndConnector = !!state.omitEndConnector; popoutOmitEndConnector = !!state.omitEndConnector;
_setSurfaceDescriptor(popoutScreen, "popout", Object.assign({}, state, {
"kind": "popout",
"screenName": popoutScreen,
"visible": popoutVisible,
"presented": state.presented !== undefined ? !!state.presented : popoutVisible,
"barSide": popoutBarSide,
"bodyX": popoutBodyX,
"bodyY": popoutBodyY,
"bodyW": popoutBodyW,
"bodyH": popoutBodyH,
"animX": popoutAnimX,
"animY": popoutAnimY,
"omitStartConnector": popoutOmitStartConnector,
"omitEndConnector": popoutOmitEndConnector
}), claimId);
return true; return true;
} }
@@ -91,6 +258,7 @@ Singleton {
if (!hasPopoutOwner(claimId)) if (!hasPopoutOwner(claimId))
return false; return false;
const releasedScreen = popoutScreen;
popoutOwnerId = ""; popoutOwnerId = "";
popoutVisible = false; popoutVisible = false;
popoutBarSide = "top"; popoutBarSide = "top";
@@ -103,6 +271,8 @@ Singleton {
popoutScreen = ""; popoutScreen = "";
popoutOmitStartConnector = false; popoutOmitStartConnector = false;
popoutOmitEndConnector = false; popoutOmitEndConnector = false;
_clearSurfaceDescriptor(releasedScreen, "popout", claimId);
_bumpSurfaceRevision(releasedScreen);
return true; return true;
} }
@@ -119,6 +289,7 @@ Singleton {
if (!isNaN(nextY) && popoutAnimY !== nextY) if (!isNaN(nextY) && popoutAnimY !== nextY)
popoutAnimY = nextY; popoutAnimY = nextY;
} }
_setSurfaceAnimation(popoutScreen, "popout", claimId, animX, animY);
return true; return true;
} }
@@ -145,6 +316,7 @@ Singleton {
if (!isNaN(nextH) && popoutBodyH !== nextH) if (!isNaN(nextH) && popoutBodyH !== nextH)
popoutBodyH = nextH; popoutBodyH = nextH;
} }
_setSurfaceBody(popoutScreen, "popout", claimId, bodyX, bodyY, bodyW, bodyH);
return true; return true;
} }
@@ -172,12 +344,23 @@ Singleton {
return false; return false;
const normalized = _normalizeDockState(state); const normalized = _normalizeDockState(state);
if (_sameDockState(dockStates[screenName], normalized)) const descriptorState = Object.assign({}, state, normalized, {
return true; "kind": "dock",
"screenName": screenName,
"visible": normalized.reveal,
"presented": normalized.reveal,
"phase": normalized.reveal ? (state.phase || "open") : "hidden"
});
const previous = dockStates[screenName] || emptyDockState;
const legacyChanged = !_sameDockState(dockStates[screenName], normalized);
if (legacyChanged) {
const next = _cloneDict(dockStates); const next = _cloneDict(dockStates);
next[screenName] = normalized; next[screenName] = normalized;
dockStates = next; dockStates = next;
}
_setSurfaceDescriptor(screenName, "dock", descriptorState, "dock:" + screenName);
if (!!previous.reveal !== !!normalized.reveal)
_bumpSurfaceRevision(screenName);
return true; return true;
} }
@@ -188,6 +371,7 @@ Singleton {
const next = _cloneDict(dockStates); const next = _cloneDict(dockStates);
delete next[screenName]; delete next[screenName];
dockStates = next; dockStates = next;
_clearSurfaceDescriptor(screenName, "dock");
// Also clear corresponding slide // Also clear corresponding slide
if (dockSlides[screenName]) { if (dockSlides[screenName]) {
@@ -195,6 +379,7 @@ Singleton {
delete nextSlides[screenName]; delete nextSlides[screenName];
dockSlides = nextSlides; dockSlides = nextSlides;
} }
_bumpSurfaceRevision(screenName);
return true; return true;
} }
@@ -212,6 +397,7 @@ Singleton {
"y": numY "y": numY
}; };
dockSlides = next; dockSlides = next;
_setSurfaceAnimation(screenName, "dock", "dock:" + screenName, numX, numY);
return true; return true;
} }
@@ -258,12 +444,22 @@ Singleton {
return false; return false;
const normalized = _normalizeNotificationState(state); const normalized = _normalizeNotificationState(state);
if (_sameNotificationState(notificationStates[screenName], normalized)) const descriptorState = Object.assign({}, state, normalized, {
return true; "kind": "notification",
"screenName": screenName,
"presented": normalized.visible,
"phase": normalized.visible ? (state.phase || "open") : "hidden"
});
const previous = notificationStates[screenName] || emptyNotificationState;
const legacyChanged = !_sameNotificationState(notificationStates[screenName], normalized);
if (legacyChanged) {
const next = _cloneDict(notificationStates); const next = _cloneDict(notificationStates);
next[screenName] = normalized; next[screenName] = normalized;
notificationStates = next; notificationStates = next;
}
_setSurfaceDescriptor(screenName, "notification", descriptorState, "notification:" + screenName);
if (!!previous.visible !== !!normalized.visible)
_bumpSurfaceRevision(screenName);
return true; return true;
} }
@@ -274,6 +470,8 @@ Singleton {
const next = _cloneDict(notificationStates); const next = _cloneDict(notificationStates);
delete next[screenName]; delete next[screenName];
notificationStates = next; notificationStates = next;
_clearSurfaceDescriptor(screenName, "notification");
_bumpSurfaceRevision(screenName);
return true; return true;
} }
@@ -330,52 +528,81 @@ Singleton {
modalOwners = nextOwners; modalOwners = nextOwners;
} }
const normalized = _normalizeModalState(state); const normalized = _normalizeModalState(state);
if (_sameModalState(modalStates[screenName], normalized))
return true;
const next = _cloneDict(modalStates); const next = _cloneDict(modalStates);
next[screenName] = normalized; next[screenName] = normalized;
modalStates = next; modalStates = next;
_setSurfaceDescriptor(screenName, "modal", Object.assign({}, state, normalized, {
"kind": state.kind || "modal",
"screenName": screenName
}), ownerId || "");
_bumpSurfaceRevision(screenName);
return true; return true;
} }
function updateModalState(screenName, state, ownerId) { function updateModalState(screenName, state, ownerId) {
if (!screenName || !state) if (!screenName || !state)
return false; return false;
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId) if (ownerId && modalOwners[screenName] !== ownerId)
return false; return false;
const normalized = _normalizeModalState(state); const normalized = _normalizeModalState(state);
if (_sameModalState(modalStates[screenName], normalized)) const descriptorState = Object.assign({}, state, normalized, {
return true; "kind": state.kind || (surfaceDescriptor(screenName, "modal").kind || "modal"),
"screenName": screenName
});
if (!_sameModalState(modalStates[screenName], normalized)) {
const next = _cloneDict(modalStates); const next = _cloneDict(modalStates);
next[screenName] = normalized; next[screenName] = normalized;
modalStates = next; modalStates = next;
}
_setSurfaceDescriptor(screenName, "modal", descriptorState, ownerId || modalOwners[screenName] || "");
return true; return true;
} }
function hasModalOwner(screenName, ownerId) {
return !!screenName && !!ownerId && modalOwners[screenName] === ownerId;
}
function ensureModalState(screenName, state, ownerId) {
if (!screenName || !state || !ownerId)
return false;
const currentOwner = modalOwners[screenName] || "";
if (currentOwner && currentOwner !== ownerId)
return false;
if (!currentOwner)
return claimModalState(screenName, state, ownerId);
return updateModalState(screenName, state, ownerId);
}
function setModalState(screenName, state) { function setModalState(screenName, state) {
return updateModalState(screenName, state, null); return updateModalState(screenName, state, null);
} }
function clearModalState(screenName, ownerId) { function clearModalState(screenName, ownerId) {
if (!screenName || !modalStates[screenName]) if (!screenName)
return false; return false;
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId) if (ownerId && modalOwners[screenName] !== ownerId)
return false;
if (!modalStates[screenName] && !modalOwners[screenName])
return false; return false;
if (modalStates[screenName]) {
const next = _cloneDict(modalStates); const next = _cloneDict(modalStates);
delete next[screenName]; delete next[screenName];
modalStates = next; modalStates = next;
}
if (modalOwners[screenName]) { if (modalOwners[screenName]) {
const nextOwners = _cloneDict(modalOwners); const nextOwners = _cloneDict(modalOwners);
delete nextOwners[screenName]; delete nextOwners[screenName];
modalOwners = nextOwners; modalOwners = nextOwners;
} }
_clearSurfaceDescriptor(screenName, "modal", ownerId);
_bumpSurfaceRevision(screenName);
return true; return true;
} }
function setModalAnim(screenName, animX, animY, ownerId) { function setModalAnim(screenName, animX, animY, ownerId) {
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId) if (ownerId && modalOwners[screenName] !== ownerId)
return false; return false;
const cur = screenName ? modalStates[screenName] : null; const cur = screenName ? modalStates[screenName] : null;
if (!cur) if (!cur)
@@ -390,11 +617,12 @@ Singleton {
"animY": nay "animY": nay
}); });
modalStates = next; modalStates = next;
_setSurfaceAnimation(screenName, "modal", ownerId, animX, animY);
return true; return true;
} }
function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH, ownerId) { function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH, ownerId) {
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId) if (ownerId && modalOwners[screenName] !== ownerId)
return false; return false;
const cur = screenName ? modalStates[screenName] : null; const cur = screenName ? modalStates[screenName] : null;
if (!cur) if (!cur)
@@ -413,6 +641,7 @@ Singleton {
"bodyH": nh "bodyH": nh
}); });
modalStates = next; modalStates = next;
_setSurfaceBody(screenName, "modal", ownerId, bodyX, bodyY, bodyW, bodyH);
return true; return true;
} }
@@ -492,6 +721,12 @@ Singleton {
const nextModalOwners = pruneKeyed(modalOwners); const nextModalOwners = pruneKeyed(modalOwners);
if (nextModalOwners !== null) if (nextModalOwners !== null)
modalOwners = nextModalOwners; modalOwners = nextModalOwners;
const nextSurfaceRevisions = pruneKeyed(surfaceRevisions);
if (nextSurfaceRevisions !== null)
surfaceRevisions = nextSurfaceRevisions;
const nextDescriptors = pruneKeyed(surfaceDescriptors);
if (nextDescriptors !== null)
surfaceDescriptors = nextDescriptors;
let retractChanged = false; let retractChanged = false;
const nextRetract = {}; const nextRetract = {};
@@ -512,7 +747,12 @@ Singleton {
Connections { Connections {
target: Quickshell target: Quickshell
function onScreensChanged() { function onScreensChanged() {
root._pruneToLiveScreens(); screenPruneAction.schedule();
} }
} }
DeferredAction {
id: screenPruneAction
onTriggered: root._pruneToLiveScreens()
}
} }
@@ -0,0 +1,179 @@
.pragma library
var VALID_KINDS = {
"popout": true,
"modal": true,
"launcher": true,
"dock": true,
"notification": true
};
var VALID_PHASES = {
"opening": true,
"open": true,
"closing": true,
"hidden": true,
"recovering": true
};
function _number(value, fallback) {
var n = Number(value);
return isNaN(n) ? fallback : n;
}
function _bool(value, fallback) {
return value === undefined ? fallback : !!value;
}
function _kind(value, fallback) {
if (VALID_KINDS[value])
return value;
return VALID_KINDS[fallback] ? fallback : "modal";
}
function _defaultBarSide(kind) {
return kind === "popout" || kind === "notification" ? "top" : "bottom";
}
function _barSide(value, fallback) {
if (value === "top" || value === "bottom" || value === "left" || value === "right")
return value;
return fallback;
}
function slotForKind(kind) {
return kind === "launcher" ? "modal" : _kind(kind, "modal");
}
function inferPhase(visible, presented, requestedPhase) {
if (VALID_PHASES[requestedPhase])
return requestedPhase;
if (!visible && !presented)
return "hidden";
if (!visible && presented)
return "closing";
return "open";
}
function normalize(input, defaults) {
var source = input || {};
var base = defaults || {};
var kind = _kind(source.kind, base.kind);
var defaultSide = _defaultBarSide(kind);
var sourceRect = source.bodyRect || {};
var baseRect = base.bodyRect || {};
var sourceOffset = source.animationOffset || {};
var baseOffset = base.animationOffset || {};
var visible = _bool(source.visible !== undefined ? source.visible : source.reveal, _bool(base.visible !== undefined ? base.visible : base.reveal, false));
var presented = _bool(source.presented, _bool(base.presented, visible));
var bodyRect = {
"x": _number(sourceRect.x !== undefined ? sourceRect.x : source.bodyX, _number(baseRect.x !== undefined ? baseRect.x : base.bodyX, 0)),
"y": _number(sourceRect.y !== undefined ? sourceRect.y : source.bodyY, _number(baseRect.y !== undefined ? baseRect.y : base.bodyY, 0)),
"width": Math.max(0, _number(sourceRect.width !== undefined ? sourceRect.width : source.bodyW, _number(baseRect.width !== undefined ? baseRect.width : base.bodyW, 0))),
"height": Math.max(0, _number(sourceRect.height !== undefined ? sourceRect.height : source.bodyH, _number(baseRect.height !== undefined ? baseRect.height : base.bodyH, 0)))
};
var animationOffset = {
"x": _number(sourceOffset.x !== undefined ? sourceOffset.x : (source.animX !== undefined ? source.animX : source.slideX), _number(baseOffset.x !== undefined ? baseOffset.x : (base.animX !== undefined ? base.animX : base.slideX), 0)),
"y": _number(sourceOffset.y !== undefined ? sourceOffset.y : (source.animY !== undefined ? source.animY : source.slideY), _number(baseOffset.y !== undefined ? baseOffset.y : (base.animY !== undefined ? base.animY : base.slideY), 0))
};
var screenName = source.screenName !== undefined ? source.screenName : (source.screen !== undefined ? source.screen : (base.screenName !== undefined ? base.screenName : base.screen));
var opacity = Math.max(0, Math.min(1, _number(source.opacity, _number(base.opacity, 1))));
return {
"ownerId": String(source.ownerId !== undefined ? source.ownerId : (base.ownerId || "")),
"kind": kind,
"screenName": String(screenName || ""),
"phase": inferPhase(visible, presented, source.phase !== undefined ? source.phase : base.phase),
"visible": visible,
"presented": presented,
"barSide": _barSide(source.barSide, _barSide(base.barSide, defaultSide)),
"bodyRect": bodyRect,
"animationOffset": animationOffset,
"scale": Math.max(0, _number(source.scale, _number(base.scale, 1))),
"opacity": opacity,
"omitStartConnector": _bool(source.omitStartConnector, _bool(base.omitStartConnector, false)),
"omitEndConnector": _bool(source.omitEndConnector, _bool(base.omitEndConnector, false)),
"dockRetractSide": String(source.dockRetractSide !== undefined ? source.dockRetractSide : (base.dockRetractSide || "")),
"revision": Math.max(0, Math.floor(_number(source.revision, _number(base.revision, 0))))
};
}
function empty(kind, screenName) {
return normalize({
"kind": kind,
"screenName": screenName || "",
"phase": "hidden",
"visible": false,
"presented": false
});
}
function withRevision(descriptor, revision) {
var next = normalize(descriptor);
next.revision = Math.max(0, Math.floor(_number(revision, next.revision)));
return next;
}
function withAnimationOffset(descriptor, x, y) {
var next = normalize(descriptor);
next.animationOffset = {
"x": x === undefined ? next.animationOffset.x : _number(x, next.animationOffset.x),
"y": y === undefined ? next.animationOffset.y : _number(y, next.animationOffset.y)
};
return next;
}
function withBodyRect(descriptor, x, y, width, height) {
var next = normalize(descriptor);
next.bodyRect = {
"x": x === undefined ? next.bodyRect.x : _number(x, next.bodyRect.x),
"y": y === undefined ? next.bodyRect.y : _number(y, next.bodyRect.y),
"width": width === undefined ? next.bodyRect.width : Math.max(0, _number(width, next.bodyRect.width)),
"height": height === undefined ? next.bodyRect.height : Math.max(0, _number(height, next.bodyRect.height))
};
return next;
}
function same(a, b, threshold) {
if (!a || !b)
return false;
var epsilon = threshold === undefined ? 0.5 : Math.max(0, Number(threshold));
return a.ownerId === b.ownerId
&& a.kind === b.kind
&& a.screenName === b.screenName
&& a.phase === b.phase
&& a.visible === b.visible
&& a.presented === b.presented
&& a.barSide === b.barSide
&& Math.abs(a.bodyRect.x - b.bodyRect.x) < epsilon
&& Math.abs(a.bodyRect.y - b.bodyRect.y) < epsilon
&& Math.abs(a.bodyRect.width - b.bodyRect.width) < epsilon
&& Math.abs(a.bodyRect.height - b.bodyRect.height) < epsilon
&& Math.abs(a.animationOffset.x - b.animationOffset.x) < epsilon
&& Math.abs(a.animationOffset.y - b.animationOffset.y) < epsilon
&& Math.abs(a.scale - b.scale) < 0.0001
&& Math.abs(a.opacity - b.opacity) < 0.0001
&& a.omitStartConnector === b.omitStartConnector
&& a.omitEndConnector === b.omitEndConnector
&& a.dockRetractSide === b.dockRetractSide;
}
function toLegacyState(descriptor) {
var d = normalize(descriptor);
return {
"visible": d.visible,
"reveal": d.visible,
"barSide": d.barSide,
"bodyX": d.bodyRect.x,
"bodyY": d.bodyRect.y,
"bodyW": d.bodyRect.width,
"bodyH": d.bodyRect.height,
"animX": d.animationOffset.x,
"animY": d.animationOffset.y,
"slideX": d.animationOffset.x,
"slideY": d.animationOffset.y,
"screen": d.screenName,
"omitStartConnector": d.omitStartConnector,
"omitEndConnector": d.omitEndConnector
};
}
@@ -0,0 +1,232 @@
.pragma library
function _number(value, fallback) {
var n = Number(value);
return isNaN(n) ? fallback : n;
}
function snap(value, dpr) {
var scale = dpr || 1;
return Math.round(_number(value, 0) * scale) / scale;
}
function isHorizontal(side) {
return side === "top" || side === "bottom";
}
function isVertical(side) {
return side === "left" || side === "right";
}
function bodyRect(descriptor, dpr) {
var source = descriptor && descriptor.bodyRect ? descriptor.bodyRect : descriptor || {};
return {
"x": snap(source.x !== undefined ? source.x : source.bodyX, dpr),
"y": snap(source.y !== undefined ? source.y : source.bodyY, dpr),
"width": Math.max(0, snap(source.width !== undefined ? source.width : source.bodyW, dpr)),
"height": Math.max(0, snap(source.height !== undefined ? source.height : source.bodyH, dpr))
};
}
function animatedBodyRect(descriptor, dpr) {
var rect = bodyRect(descriptor, dpr);
var offset = descriptor && descriptor.animationOffset ? descriptor.animationOffset : descriptor || {};
var side = descriptor && descriptor.barSide ? descriptor.barSide : "bottom";
var dx = isVertical(side) ? Math.max(-rect.width, Math.min(_number(offset.x !== undefined ? offset.x : offset.animX, 0), rect.width)) : 0;
var dy = isHorizontal(side) ? Math.max(-rect.height, Math.min(_number(offset.y !== undefined ? offset.y : offset.animY, 0), rect.height)) : 0;
return {
"x": snap(rect.x + (side === "right" ? dx : 0), dpr),
"y": snap(rect.y + (side === "bottom" ? dy : 0), dpr),
"width": Math.max(0, snap(rect.width - Math.abs(dx), dpr)),
"height": Math.max(0, snap(rect.height - Math.abs(dy), dpr)),
"dx": snap(dx, dpr),
"dy": snap(dy, dpr)
};
}
function translatedBodyRect(descriptor, dpr) {
var rect = bodyRect(descriptor, dpr);
var offset = descriptor && descriptor.animationOffset ? descriptor.animationOffset : {};
return {
"x": snap(rect.x + _number(offset.x, 0), dpr),
"y": snap(rect.y + _number(offset.y, 0), dpr),
"width": rect.width,
"height": rect.height
};
}
function connectorRadii(descriptor, rect, connectedRadius, surfaceRadius, dpr, nearIncludesSurface) {
var side = descriptor && descriptor.barSide ? descriptor.barSide : "bottom";
var horizontal = isHorizontal(side);
var extent = horizontal ? rect.height : rect.width;
var crossSize = horizontal ? rect.width : rect.height;
var nearLimit = nearIncludesSurface ? Math.min(connectedRadius, surfaceRadius, extent, crossSize / 2) : Math.min(connectedRadius, extent, crossSize / 2);
var farLimit = Math.min(connectedRadius, surfaceRadius, crossSize / 2);
var near = snap(Math.max(0, nearLimit), dpr);
var far = snap(Math.max(0, farLimit), dpr);
var omitStart = !!(descriptor && descriptor.omitStartConnector);
var omitEnd = !!(descriptor && descriptor.omitEndConnector);
return {
"near": near,
"far": far,
"start": omitStart ? 0 : near,
"end": omitEnd ? 0 : near,
"farStart": omitStart ? far : 0,
"farEnd": omitEnd ? far : 0,
"farExtent": Math.max(omitStart ? far : 0, omitEnd ? far : 0)
};
}
function _connectorWidth(side, spacing, radius) {
return isVertical(side) ? spacing + radius : radius;
}
function _connectorHeight(side, spacing, radius) {
return isVertical(side) ? radius : spacing + radius;
}
function connectorRect(side, rect, placement, spacing, radius, dpr) {
var width = _connectorWidth(side, spacing, radius);
var height = _connectorHeight(side, spacing, radius);
var seamX = isVertical(side) ? (side === "left" ? rect.x : rect.x + rect.width) : (placement === "left" ? rect.x : rect.x + rect.width);
var seamY = side === "top" ? rect.y : (side === "bottom" ? rect.y + rect.height : (placement === "left" ? rect.y : rect.y + rect.height));
var x = isVertical(side) ? (side === "left" ? seamX : seamX - width) : (placement === "left" ? seamX - width : seamX);
var y = side === "top" ? seamY : (side === "bottom" ? seamY - height : (placement === "left" ? seamY - height : seamY));
return {
"x": snap(x, dpr),
"y": snap(y, dpr),
"width": Math.max(0, snap(width, dpr)),
"height": Math.max(0, snap(height, dpr))
};
}
function farConnectorRect(side, rect, placement, radius, dpr) {
var x;
var y;
if (isHorizontal(side)) {
x = placement === "left" ? rect.x : rect.x + rect.width - radius;
y = side === "top" ? rect.y + rect.height : rect.y - radius;
} else {
x = side === "left" ? rect.x + rect.width : rect.x - radius;
y = placement === "left" ? rect.y : rect.y + rect.height - radius;
}
return {
"x": snap(x, dpr),
"y": snap(y, dpr),
"width": Math.max(0, snap(radius, dpr)),
"height": Math.max(0, snap(radius, dpr))
};
}
function farBodyCapRect(side, rect, placement, radius, dpr) {
var x;
var y;
if (isHorizontal(side)) {
x = placement === "left" ? rect.x : rect.x + rect.width - radius;
y = side === "top" ? rect.y + rect.height - radius : rect.y;
} else {
x = side === "left" ? rect.x + rect.width - radius : rect.x;
y = placement === "left" ? rect.y : rect.y + rect.height - radius;
}
return {
"x": snap(x, dpr),
"y": snap(y, dpr),
"width": Math.max(0, snap(radius, dpr)),
"height": Math.max(0, snap(radius, dpr))
};
}
function chromeBounds(rect, side, startRadius, endRadius, farExtent, dpr) {
var horizontal = isHorizontal(side);
var bodyOffsetX = horizontal ? startRadius : (side === "right" ? farExtent : 0);
var bodyOffsetY = horizontal ? (side === "bottom" ? farExtent : 0) : startRadius;
return {
"x": snap(rect.x - bodyOffsetX, dpr),
"y": snap(rect.y - bodyOffsetY, dpr),
"width": Math.max(0, snap(horizontal ? rect.width + startRadius + endRadius : rect.width + farExtent, dpr)),
"height": Math.max(0, snap(horizontal ? rect.height + farExtent : rect.height + startRadius + endRadius, dpr)),
"bodyOffsetX": snap(bodyOffsetX, dpr),
"bodyOffsetY": snap(bodyOffsetY, dpr)
};
}
function fillBounds(rect, side, seamOverlap, dpr) {
var overlapX = isHorizontal(side) ? seamOverlap : 0;
var overlapY = isVertical(side) ? seamOverlap : 0;
return {
"x": snap(rect.x - overlapX, dpr),
"y": snap(rect.y - overlapY, dpr),
"width": Math.max(0, snap(rect.width + overlapX * 2, dpr)),
"height": Math.max(0, snap(rect.height + overlapY * 2, dpr))
};
}
function clipEnvelope(rect, side, radii, seamOverlap, dpr) {
var fill = fillBounds(rect, side, seamOverlap, dpr);
var chrome = chromeBounds(fill, side, radii.start, radii.end, radii.farExtent, dpr);
return {
"x": chrome.x,
"y": chrome.y,
"width": chrome.width,
"height": chrome.height,
"bodyX": snap(fill.x - chrome.x, dpr),
"bodyY": snap(fill.y - chrome.y, dpr),
"bodyWidth": fill.width,
"bodyHeight": fill.height
};
}
function blurRegions(descriptor, rect, radii, dpr) {
var side = descriptor.barSide;
var regions = [bodyRect(rect, dpr)];
if (radii.start > 0)
regions.push(connectorRect(side, rect, "left", 0, radii.start, dpr));
if (radii.end > 0)
regions.push(connectorRect(side, rect, "right", 0, radii.end, dpr));
if (radii.farStart > 0) {
regions.push(farConnectorRect(side, rect, "left", radii.farStart, dpr));
regions.push(farBodyCapRect(side, rect, "left", radii.farStart, dpr));
}
if (radii.farEnd > 0) {
regions.push(farConnectorRect(side, rect, "right", radii.farEnd, dpr));
regions.push(farBodyCapRect(side, rect, "right", radii.farEnd, dpr));
}
return regions;
}
function unionBounds(rects, padding, dpr) {
var minX = Infinity;
var minY = Infinity;
var maxX = -Infinity;
var maxY = -Infinity;
for (var i = 0; i < rects.length; i++) {
var rect = rects[i];
if (!rect || rect.width <= 0 || rect.height <= 0)
continue;
minX = Math.min(minX, rect.x);
minY = Math.min(minY, rect.y);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
if (minX === Infinity)
return {"x": 0, "y": 0, "width": 0, "height": 0};
var pad = Math.max(0, _number(padding, 0));
return {
"x": snap(minX - pad, dpr),
"y": snap(minY - pad, dpr),
"width": Math.max(0, snap(maxX - minX + pad * 2, dpr)),
"height": Math.max(0, snap(maxY - minY + pad * 2, dpr))
};
}
function shadowSourceBounds(descriptor, rect, radii, padding, dpr) {
return unionBounds(blurRegions(descriptor, rect, radii, dpr), padding, dpr);
}
function stableEqual(a, b, dpr) {
if (!a || !b)
return false;
var threshold = 0.5 / (dpr || 1);
return Math.abs(a.x - b.x) < threshold && Math.abs(a.y - b.y) < threshold && Math.abs(a.width - b.width) < threshold && Math.abs(a.height - b.height) < threshold;
}
+176
View File
@@ -0,0 +1,176 @@
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
required property string claimPrefix
required property var isCurrentOwner
required property var hasOwner
required property var claimState
required property var ensureState
required property var releaseState
property var statePresent: null
property var updateAnimationState: null
property var updateBodyState: null
property var requestDockRetract: null
property var releaseDockRetract: null
property string screenName: ""
property bool enabled: false
property bool active: false
property bool presented: false
property bool dockBlocked: false
property string dockSide: ""
property bool renewTokenOnRecovery: true
property string claimId: ""
property string claimedScreenName: ""
property int _claimSerial: 0
signal recoveryRequested
visible: false
function _nextClaimId() {
_claimSerial += 1;
return claimPrefix + ":" + (new Date()).getTime() + ":" + _claimSerial + ":" + Math.floor(Math.random() * 1000000);
}
function _isCurrent(name) {
return !!name && !!isCurrentOwner && !!isCurrentOwner(name);
}
function _hasOwner(name, ownerId) {
return !!name && !!ownerId && !!hasOwner && !!hasOwner(name, ownerId);
}
function _hasState(name, ownerId) {
return !statePresent || !!statePresent(name, ownerId);
}
function _shouldRecover() {
return active && enabled && _isCurrent(screenName);
}
function requestRecovery() {
if (!_shouldRecover())
return false;
recoveryRequested();
return true;
}
function checkOwnershipRecovery() {
if (!_shouldRecover())
return false;
if (claimedScreenName === screenName && _hasOwner(screenName, claimId))
return false;
recoveryRequested();
return true;
}
function checkStateRecovery() {
if (!_shouldRecover())
return false;
if (claimedScreenName === screenName && _hasOwner(screenName, claimId) && _hasState(screenName, claimId))
return false;
recoveryRequested();
return true;
}
function checkRecovery() {
return checkStateRecovery();
}
function beginClaim() {
if (claimId && releaseDockRetract)
releaseDockRetract(claimId);
claimId = _nextClaimId();
claimedScreenName = "";
return claimId;
}
function _syncDockRetract() {
if (!claimId)
return;
if (dockBlocked && presented && dockSide && requestDockRetract)
requestDockRetract(claimId, screenName, dockSide);
else if (releaseDockRetract)
releaseDockRetract(claimId);
}
function publish(state, forceClaim) {
if (!enabled || !screenName || !state) {
release();
return false;
}
if (claimedScreenName && claimedScreenName !== screenName)
release();
const current = _isCurrent(screenName);
let claiming = !!forceClaim || !claimId;
if (claiming && !current)
return false;
if (!claimId)
beginClaim();
let published = claiming ? claimState(screenName, state, claimId) : ensureState(screenName, state, claimId);
if (!published && !claiming && current) {
if (renewTokenOnRecovery) {
beginClaim();
} else if (releaseDockRetract) {
releaseDockRetract(claimId);
}
published = claimState(screenName, state, claimId);
}
if (!published)
return false;
claimedScreenName = screenName;
_syncDockRetract();
return true;
}
function updateAnim(animX, animY) {
if (!enabled || !claimId || !claimedScreenName || !updateAnimationState)
return false;
if (!_hasOwner(claimedScreenName, claimId)) {
requestRecovery();
return false;
}
return updateAnimationState(claimedScreenName, claimId, animX, animY);
}
function updateBody(bodyX, bodyY, bodyW, bodyH) {
if (!enabled || !claimId || !claimedScreenName || !updateBodyState)
return false;
if (!_hasOwner(claimedScreenName, claimId)) {
requestRecovery();
return false;
}
return updateBodyState(claimedScreenName, claimId, bodyX, bodyY, bodyW, bodyH);
}
function release() {
if (!claimId) {
claimedScreenName = "";
return false;
}
const releasedClaimId = claimId;
const releasedScreenName = claimedScreenName;
claimId = "";
claimedScreenName = "";
if (releaseDockRetract)
releaseDockRetract(releasedClaimId);
if (releasedScreenName)
return !!releaseState(releasedScreenName, releasedClaimId);
return false;
}
Component.onDestruction: release()
}
+10
View File
@@ -57,9 +57,15 @@ const KEY_MAP = {
16842802: "XF86Eject", 16842802: "XF86Eject",
16842791: "XF86Calculator", 16842791: "XF86Calculator",
16842806: "XF86Explorer", 16842806: "XF86Explorer",
16777360: "XF86HomePage",
16842794: "XF86HomePage", 16842794: "XF86HomePage",
16777362: "XF86Search",
16777426: "XF86Search", 16777426: "XF86Search",
16777376: "XF86Mail",
16777427: "XF86Mail", 16777427: "XF86Mail",
16777377: "XF86AudioMedia",
16777419: "XF86Calculator",
16777429: "XF86Explorer",
16777442: "XF86Launch0", 16777442: "XF86Launch0",
16777443: "XF86Launch1", 16777443: "XF86Launch1",
33: "1", 33: "1",
@@ -129,6 +135,10 @@ function xkbKeyFromQtKey(qk) {
return String.fromCharCode(qk); return String.fromCharCode(qk);
if (qk >= 16777264 && qk <= 16777298) if (qk >= 16777264 && qk <= 16777298)
return "F" + (qk - 16777264 + 1); return "F" + (qk - 16777264 + 1);
if (qk >= 16777378 && qk <= 16777387)
return "XF86Launch" + (qk - 16777378);
if (qk >= 16777388 && qk <= 16777393)
return "XF86Launch" + String.fromCharCode(65 + qk - 16777388);
return KEY_MAP[qk] || ""; return KEY_MAP[qk] || "";
} }
+33
View File
@@ -11,6 +11,15 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call spotlight toggle", label: "Default Launcher: Toggle" }, { id: "spawn dms ipc call spotlight toggle", label: "Default Launcher: Toggle" },
{ id: "spawn dms ipc call spotlight open", label: "Default Launcher: Open" }, { id: "spawn dms ipc call spotlight open", label: "Default Launcher: Open" },
{ id: "spawn dms ipc call spotlight close", label: "Default Launcher: Close" }, { id: "spawn dms ipc call spotlight close", label: "Default Launcher: Close" },
{ id: "spawn dms ipc call defaultApp browser", label: "Default Web Browser: Open" },
{ id: "spawn dms ipc call defaultApp fileManager", label: "Default File Manager: Open" },
{ id: "spawn dms ipc call defaultApp mail", label: "Default Mail: Open" },
{ id: "spawn dms ipc call defaultApp calendar", label: "Default Calendar: Open" },
{ id: "spawn dms ipc call defaultApp textEditor", label: "Default Text Editor: Open" },
{ id: "spawn dms ipc call defaultApp pdfReader", label: "Default PDF Reader: Open" },
{ id: "spawn dms ipc call defaultApp imageViewer", label: "Default Image Viewer: Open" },
{ id: "spawn dms ipc call defaultApp videoPlayer", label: "Default Video Player: Open" },
{ id: "spawn dms ipc call defaultApp musicPlayer", label: "Default Music Player: Open" },
{ id: "spawn dms ipc call spotlight-bar toggle", label: "Spotlight Bar: Toggle" }, { id: "spawn dms ipc call spotlight-bar toggle", label: "Spotlight Bar: Toggle" },
{ id: "spawn dms ipc call spotlight-bar open", label: "Spotlight Bar: Open" }, { id: "spawn dms ipc call spotlight-bar open", label: "Spotlight Bar: Open" },
{ id: "spawn dms ipc call spotlight-bar close", label: "Spotlight Bar: Close" }, { id: "spawn dms ipc call spotlight-bar close", label: "Spotlight Bar: Close" },
@@ -770,6 +779,26 @@ const DMS_ACTION_ARGS = {
} }
}; };
const DMS_AMOUNT_LABELS = {
"audio increment": "Volume Up",
"audio decrement": "Volume Down",
"mpris increment": "Player Volume Up",
"mpris decrement": "Player Volume Down",
"brightness increment": "Brightness Up",
"brightness decrement": "Brightness Down"
};
function getDmsAmountLabel(action) {
var parsed = parseDmsActionArgs(action);
var label = DMS_AMOUNT_LABELS[parsed.base];
if (!label)
return null;
var amount = parsed.args?.amount;
if (amount === undefined || amount === null || amount === "")
return label;
return label + " (" + amount + "%)";
}
function getActionTypes() { function getActionTypes() {
return ACTION_TYPES; return ACTION_TYPES;
} }
@@ -844,6 +873,10 @@ function getActionLabel(action, compositor) {
if (!action) if (!action)
return ""; return "";
var amountLabel = getDmsAmountLabel(action);
if (amountLabel)
return amountLabel;
var dmsAct = findDmsAction(action); var dmsAct = findDmsAction(action);
if (dmsAct) if (dmsAct)
return dmsAct.label; return dmsAct.label;
+91
View File
@@ -0,0 +1,91 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("LayerShell")
function _toLayer(name) {
switch (name) {
case "background":
return WlrLayer.Background;
case "bottom":
return WlrLayer.Bottom;
case "top":
return WlrLayer.Top;
case "overlay":
return WlrLayer.Overlay;
}
return undefined;
}
function _toName(layer) {
switch (layer) {
case WlrLayer.Background:
return "background";
case WlrLayer.Bottom:
return "bottom";
case WlrLayer.Top:
return "top";
case WlrLayer.Overlay:
return "overlay";
}
return "top";
}
// Resolve a WlrLayer from a DMS_*_LAYER env override.
// name: env var to read, e.g. "DMS_OSD_LAYER"
// fallback: WlrLayer used when the var is unset or unrecognized
// opts (optional):
// allow: array of honored layer names; recognized names outside it
// are treated as invalid
// invalidLayer: WlrLayer used for a recognized-but-disallowed value
// (default: fallback)
// label: context for the diagnostic, e.g. "OSDs"; omit to stay silent
// error: log at error level instead of warn
function fromEnv(name, fallback, opts) {
const value = Quickshell.env(name);
if (!value)
return fallback;
const requested = _toLayer(value);
if (requested === undefined)
return fallback;
const allow = opts?.allow;
if (!allow || allow.indexOf(value) !== -1)
return requested;
const invalid = opts?.invalidLayer ?? fallback;
if (opts?.label) {
const msg = `'${value}' layer is not valid for ${opts.label}. Defaulting to '${_toName(invalid)}' layer.`;
if (opts?.error)
log.error(msg);
else
log.warn(msg);
}
return invalid;
}
// For call sites that only need "is the override the overlay layer?".
// Honors "overlay" (true) and bottom/background/top (false); anything else
// returns `fallback`.
function envUsesOverlay(name, fallback) {
switch (Quickshell.env(name)) {
case "overlay":
return true;
case "bottom":
case "background":
case "top":
return false;
default:
return fallback;
}
}
}
+6
View File
@@ -13,6 +13,7 @@ Singleton {
property var currentModalsByScreen: ({}) property var currentModalsByScreen: ({})
function openModal(modal) { function openModal(modal) {
PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown"; const screenName = modal.effectiveScreen?.name ?? "unknown";
currentModalsByScreen[screenName] = modal; currentModalsByScreen[screenName] = modal;
modalChanged(); modalChanged();
@@ -25,6 +26,11 @@ Singleton {
}); });
} }
function isCurrentModal(modal, screenName) {
const name = screenName || modal?.effectiveScreen?.name || "unknown";
return currentModalsByScreen[name] === modal;
}
function closeModal(modal) { function closeModal(modal) {
const screenName = modal.effectiveScreen?.name ?? "unknown"; const screenName = modal.effectiveScreen?.name ?? "unknown";
if (currentModalsByScreen[screenName] === modal) { if (currentModalsByScreen[screenName] === modal) {
+10
View File
@@ -10,6 +10,9 @@ Singleton {
property var currentPopoutsByScreen: ({}) property var currentPopoutsByScreen: ({})
property var currentPopoutTriggers: ({}) property var currentPopoutTriggers: ({})
// Set by the screenshot IPC handshake (dms screenshot region select); cleared by end() or any popout/modal open.
property bool screenshotActive: false
signal popoutOpening signal popoutOpening
signal popoutChanged signal popoutChanged
@@ -47,6 +50,7 @@ Singleton {
function showPopout(popout) { function showPopout(popout) {
if (!popout || !popout.screen) if (!popout || !popout.screen)
return; return;
screenshotActive = false;
popoutOpening(); popoutOpening();
const screenName = popout.screen.name; const screenName = popout.screen.name;
@@ -94,9 +98,15 @@ Singleton {
return currentPopoutsByScreen[screen.name] || null; return currentPopoutsByScreen[screen.name] || null;
} }
function isCurrentPopout(popout, screenName) {
const name = screenName || popout?.screen?.name || "";
return !!name && currentPopoutsByScreen[name] === popout;
}
function requestPopout(popout, tabIndex, triggerSource) { function requestPopout(popout, tabIndex, triggerSource) {
if (!popout || !popout.screen) if (!popout || !popout.screen)
return; return;
screenshotActive = false;
const screenName = popout.screen.name; const screenName = popout.screen.name;
const currentPopout = currentPopoutsByScreen[screenName]; const currentPopout = currentPopoutsByScreen[screenName];
const triggerId = triggerSource !== undefined ? triggerSource : tabIndex; const triggerId = triggerSource !== undefined ? triggerSource : tabIndex;
+34 -4
View File
@@ -154,6 +154,8 @@ Singleton {
property var trayItemOrder: [] property var trayItemOrder: []
property var recentColors: [] property var recentColors: []
property bool showThirdPartyPlugins: false property bool showThirdPartyPlugins: false
property bool pluginBrowserInstalledFirst: false
property string pluginBrowserSortMode: "default"
property string launchPrefix: "" property string launchPrefix: ""
property string lastBrightnessDevice: "" property string lastBrightnessDevice: ""
property var brightnessExponentialDevices: ({}) property var brightnessExponentialDevices: ({})
@@ -964,6 +966,20 @@ Singleton {
saveSettings(); saveSettings();
} }
function setPluginBrowserInstalledFirst(enabled) {
pluginBrowserInstalledFirst = enabled;
saveSettings();
}
function setPluginBrowserSortMode(mode) {
if (mode === "type" || mode === "contributor")
mode = "author";
if (mode !== "default" && mode !== "name" && mode !== "author" && mode !== "category")
mode = "default";
pluginBrowserSortMode = mode;
saveSettings();
}
function setLaunchPrefix(prefix) { function setLaunchPrefix(prefix) {
launchPrefix = prefix; launchPrefix = prefix;
saveSettings(); saveSettings();
@@ -1353,13 +1369,27 @@ Singleton {
} }
} }
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterSessionBaseDir: root._greeterCacheDir
function setGreeterSessionBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterSessionBaseDir === next)
return;
greeterSessionBaseDir = next;
if (isGreeterMode)
greeterSessionFile.reload();
}
function resetGreeterSessionBaseDir() {
setGreeterSessionBaseDir(root._greeterCacheDir);
}
FileView { FileView {
id: greeterSessionFile id: greeterSessionFile
path: { path: root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
return greetCfgDir + "/session.json";
}
preload: isGreeterMode preload: isGreeterMode
blockLoading: false blockLoading: false
blockWrites: true blockWrites: true
+31 -2
View File
@@ -173,9 +173,11 @@ Singleton {
property int hyprlandLayoutGapsOverride: -1 property int hyprlandLayoutGapsOverride: -1
property int hyprlandLayoutRadiusOverride: -1 property int hyprlandLayoutRadiusOverride: -1
property int hyprlandLayoutBorderSize: -1 property int hyprlandLayoutBorderSize: -1
property bool hyprlandResizeOnBorder: false
property int mangoLayoutGapsOverride: -1 property int mangoLayoutGapsOverride: -1
property int mangoLayoutRadiusOverride: -1 property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1 property int mangoLayoutBorderSize: -1
property bool mangoTrackpadNaturalScrolling: true
property int firstDayOfWeek: -1 property int firstDayOfWeek: -1
property bool showWeekNumber: false property bool showWeekNumber: false
@@ -315,6 +317,8 @@ Singleton {
property bool controlCenterShowBatteryIcon: false property bool controlCenterShowBatteryIcon: false
property bool controlCenterShowPrinterIcon: false property bool controlCenterShowPrinterIcon: false
property bool controlCenterShowScreenSharingIcon: true property bool controlCenterShowScreenSharingIcon: true
property bool controlCenterShowIdleInhibitorIcon: false
property bool controlCenterShowDoNotDisturbIcon: false
property bool showPrivacyButton: true property bool showPrivacyButton: true
property bool privacyShowMicIcon: false property bool privacyShowMicIcon: false
property bool privacyShowCameraIcon: false property bool privacyShowCameraIcon: false
@@ -370,6 +374,7 @@ Singleton {
property bool showWorkspaceApps: false property bool showWorkspaceApps: false
property bool workspaceDragReorder: true property bool workspaceDragReorder: true
property bool groupWorkspaceApps: true property bool groupWorkspaceApps: true
property bool groupActiveWorkspaceApps: false
property int maxWorkspaceIcons: 3 property int maxWorkspaceIcons: 3
property int workspaceAppIconSizeOffset: 0 property int workspaceAppIconSizeOffset: 0
property bool workspaceFollowFocus: false property bool workspaceFollowFocus: false
@@ -405,6 +410,7 @@ Singleton {
property int appsDockEnlargePercentage: 125 property int appsDockEnlargePercentage: 125
property int appsDockIconSizePercentage: 100 property int appsDockIconSizePercentage: 100
property bool keyboardLayoutNameCompactMode: false property bool keyboardLayoutNameCompactMode: false
property bool keyboardLayoutNameShowIcon: false
property bool runningAppsCurrentWorkspace: true property bool runningAppsCurrentWorkspace: true
property bool runningAppsGroupByApp: false property bool runningAppsGroupByApp: false
property bool runningAppsCurrentMonitor: false property bool runningAppsCurrentMonitor: false
@@ -414,6 +420,7 @@ Singleton {
property string lockDateFormat: "" property string lockDateFormat: ""
property bool greeterRememberLastSession: true property bool greeterRememberLastSession: true
property bool greeterRememberLastUser: true property bool greeterRememberLastUser: true
property bool greeterAutoLogin: false
property bool greeterEnableFprint: false property bool greeterEnableFprint: false
property bool greeterEnableU2f: false property bool greeterEnableU2f: false
property string greeterWallpaperPath: "" property string greeterWallpaperPath: ""
@@ -484,6 +491,9 @@ Singleton {
}, },
"dwl": { "dwl": {
"cursorHideTimeout": 0 "cursorHideTimeout": 0
},
"mango": {
"cursorHideTimeout": 0
} }
}) })
property var availableCursorThemes: ["System Default"] property var availableCursorThemes: ["System Default"]
@@ -1216,6 +1226,8 @@ Singleton {
HyprlandService.generateLayoutConfig(); HyprlandService.generateLayoutConfig();
if (CompositorService.isDwl && typeof DwlService !== "undefined") if (CompositorService.isDwl && typeof DwlService !== "undefined")
DwlService.generateLayoutConfig(); DwlService.generateLayoutConfig();
if (CompositorService.isMango && typeof MangoService !== "undefined")
MangoService.generateLayoutConfig();
} }
function applyStoredIconTheme() { function applyStoredIconTheme() {
@@ -1333,6 +1345,15 @@ Singleton {
}); });
} }
function scheduleGreeterAutoLoginSync() {
if (isGreeterMode)
return;
Qt.callLater(() => {
Processes.settingsRoot = root;
Processes.scheduleGreeterAutoLoginSync();
});
}
readonly property var _hooks: ({ readonly property var _hooks: ({
"applyStoredTheme": applyStoredTheme, "applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes, "regenSystemThemes": regenSystemThemes,
@@ -1340,7 +1361,8 @@ Singleton {
"applyStoredIconTheme": applyStoredIconTheme, "applyStoredIconTheme": applyStoredIconTheme,
"updateBarConfigs": updateBarConfigs, "updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor, "updateCompositorCursor": updateCompositorCursor,
"scheduleAuthApply": scheduleAuthApply "scheduleAuthApply": scheduleAuthApply,
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync
}) })
function set(key, value) { function set(key, value) {
@@ -2219,7 +2241,10 @@ Singleton {
function getFilteredScreens(componentId) { function getFilteredScreens(componentId) {
var prefs = screenPreferences && screenPreferences[componentId] || ["all"]; var prefs = screenPreferences && screenPreferences[componentId] || ["all"];
if (prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) { if (componentId === "wallpaper" && Array.isArray(prefs) && prefs.length === 0) {
return [];
}
if (!prefs || prefs.length === 0 || prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) {
return Quickshell.screens; return Quickshell.screens;
} }
var filtered = Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs)); var filtered = Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
@@ -2430,6 +2455,10 @@ Singleton {
DwlService.generateCursorConfig(); DwlService.generateCursorConfig();
return; return;
} }
if (CompositorService.isMango && typeof MangoService !== "undefined") {
MangoService.generateCursorConfig();
return;
}
} }
function updateXResources() { function updateXResources() {
+25
View File
@@ -0,0 +1,25 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Common
Singleton {
id: root
property string selectedBarId: "default"
function normalizeSelectedBar() {
if (SettingsData.getBarConfig(selectedBarId))
return;
selectedBarId = SettingsData.barConfigs[0]?.id ?? "default";
}
Connections {
target: SettingsData
function onBarConfigsChanged() {
root.normalizeSelectedBar();
}
}
}
+21 -3
View File
@@ -970,6 +970,7 @@ Singleton {
readonly property int shorterDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.shorter readonly property int shorterDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.shorter
readonly property int shortDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.short readonly property int shortDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.short
readonly property bool snapListModelChanges: shortDuration <= 0
readonly property int mediumDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.medium readonly property int mediumDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.medium
readonly property int longDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.long readonly property int longDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.long
readonly property int extraLongDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.extraLong readonly property int extraLongDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.extraLong
@@ -2079,12 +2080,29 @@ Singleton {
} }
} }
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterColorsBaseDir: root._greeterCacheDir
function setGreeterColorsBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterColorsBaseDir === next)
return;
greeterColorsBaseDir = next;
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode)
dynamicColorsFileView.reload();
}
function resetGreeterColorsBaseDir() {
setGreeterColorsBaseDir(root._greeterCacheDir);
}
FileView { FileView {
id: dynamicColorsFileView id: dynamicColorsFileView
path: { path: {
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"; if (SessionData.isGreeterMode)
const colorsPath = SessionData.isGreeterMode ? greetCfgDir + "/colors.json" : stateDir + "/dms-colors.json"; return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
return colorsPath; return stateDir + "/dms-colors.json";
} }
blockLoading: false blockLoading: false
watchChanges: !SessionData.isGreeterMode watchChanges: !SessionData.isGreeterMode
+165
View File
@@ -12,6 +12,35 @@ Singleton {
property var settingsRoot: null property var settingsRoot: null
onSettingsRootChanged: {
if (settingsRoot && !settingsRoot.isGreeterMode)
consumeGreeterAutoLoginPendingSync();
}
readonly property string greeterAutoLoginPendingSyncPath: (Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending"
function consumeGreeterAutoLoginPendingSync() {
if (!settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginPendingCheckProcess.running = true;
}
property var greeterAutoLoginPendingCheckProcess: Process {
command: ["sh", "-c", "if [ -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + " ]; then rm -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + "; echo pending; fi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if ((text || "").trim() !== "pending" || !root.settingsRoot)
return;
if (!root.settingsRoot.greeterAutoLogin)
root.settingsRoot.set("greeterAutoLogin", true);
else
root.scheduleGreeterAutoLoginSync();
}
}
}
property string greetdPamText: "" property string greetdPamText: ""
property string systemAuthPamText: "" property string systemAuthPamText: ""
property string commonAuthPamText: "" property string commonAuthPamText: ""
@@ -296,6 +325,66 @@ Singleton {
authApplyDebounce.restart(); authApplyDebounce.restart();
} }
// --- Greeter auto-login sync pipeline ---
property bool greeterAutoLoginSyncRunning: false
property bool greeterAutoLoginSyncQueued: false
property bool greeterAutoLoginSyncRerunRequested: false
property string greeterAutoLoginSyncStdout: ""
property string greeterAutoLoginSyncStderr: ""
property string greeterAutoLoginSyncTerminalFallbackStderr: ""
function scheduleGreeterAutoLoginSync() {
if (!settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginSyncQueued = true;
if (greeterAutoLoginSyncRunning) {
greeterAutoLoginSyncRerunRequested = true;
return;
}
greeterAutoLoginSyncDebounce.restart();
}
function beginGreeterAutoLoginSync() {
if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginSyncQueued = false;
greeterAutoLoginSyncRerunRequested = false;
greeterAutoLoginSyncStdout = "";
greeterAutoLoginSyncStderr = "";
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncRunning = true;
greeterAutoLoginSyncSudoProbeProcess.running = true;
}
function launchGreeterAutoLoginSyncTerminalFallback(details) {
ToastService.showWarning(I18n.tr("Opening terminal to update greetd"), I18n.tr("DMS needs administrator access. The terminal closes automatically when done.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin", "greeter-autologin-sync");
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncTerminalFallbackProcess.running = true;
}
function greeterAutoLoginSyncSuccessToast(details) {
const enabling = settingsRoot && settingsRoot.greeterAutoLogin;
// Clear the sticky in-progress toast, then confirm with an auto-dismissing toast.
ToastService.dismissCategory("greeter-autologin-sync");
if (enabling) {
ToastService.showWarning(I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""));
} else {
ToastService.showInfo(I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""));
}
}
function finishGreeterAutoLoginSync() {
const shouldRerun = greeterAutoLoginSyncQueued || greeterAutoLoginSyncRerunRequested;
greeterAutoLoginSyncRunning = false;
greeterAutoLoginSyncRerunRequested = false;
if (shouldRerun)
greeterAutoLoginSyncDebounce.restart();
}
// --- PAM parsing helpers --- // --- PAM parsing helpers ---
function stripPamComment(line) { function stripPamComment(line) {
@@ -433,6 +522,82 @@ Singleton {
onTriggered: root.beginAuthApply() onTriggered: root.beginAuthApply()
} }
Timer {
id: greeterAutoLoginSyncDebounce
interval: 300
repeat: false
onTriggered: root.beginGreeterAutoLoginSync()
}
property var greeterAutoLoginSyncProcess: Process {
command: ["dms", "greeter", "sync", "--yes", "--autologin"]
running: false
stdout: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncStdout = text || ""
}
stderr: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncStderr = text || ""
}
onExited: exitCode => {
const out = (root.greeterAutoLoginSyncStdout || "").trim();
const err = (root.greeterAutoLoginSyncStderr || "").trim();
if (exitCode === 0) {
let details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.greeterAutoLoginSyncSuccessToast(details);
root.finishGreeterAutoLoginSync();
return;
}
let details = "";
if (out !== "")
details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.launchGreeterAutoLoginSyncTerminalFallback(details);
}
}
property var greeterAutoLoginSyncSudoProbeProcess: Process {
command: ["sudo", "-n", "true"]
running: false
onExited: exitCode => {
const enabling = root.settingsRoot && root.settingsRoot.greeterAutoLogin;
if (exitCode === 0) {
ToastService.showWarning(enabling ? I18n.tr("Applying auto-login on startup…") : I18n.tr("Disabling auto-login on startup…"), "", "dms greeter sync --autologin", "greeter-autologin-sync");
root.greeterAutoLoginSyncProcess.running = true;
return;
}
root.launchGreeterAutoLoginSyncTerminalFallback();
}
}
property var greeterAutoLoginSyncTerminalFallbackProcess: Process {
command: ["dms", "greeter", "sync", "--terminal", "--yes", "--autologin"]
running: false
stderr: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncTerminalFallbackStderr = text || ""
}
onExited: exitCode => {
if (exitCode === 0) {
root.greeterAutoLoginSyncSuccessToast("");
} else {
let details = (root.greeterAutoLoginSyncTerminalFallbackStderr || "").trim();
ToastService.showError(I18n.tr("Couldn't open a terminal for the auto-login update.") + " (exit " + exitCode + ")", details, "dms greeter sync --autologin", "greeter-autologin-sync");
}
root.finishGreeterAutoLoginSync();
}
}
property var authApplyProcess: Process { property var authApplyProcess: Process {
command: ["dms", "auth", "sync", "--yes"] command: ["dms", "auth", "sync", "--yes"]
running: false running: false
@@ -56,6 +56,8 @@ var SPEC = {
trayItemOrder: { def: [] }, trayItemOrder: { def: [] },
recentColors: { def: [] }, recentColors: { def: [] },
showThirdPartyPlugins: { def: false }, showThirdPartyPlugins: { def: false },
pluginBrowserInstalledFirst: { def: false },
pluginBrowserSortMode: { def: "default" },
launchPrefix: { def: "" }, launchPrefix: { def: "" },
lastBrightnessDevice: { def: "" }, lastBrightnessDevice: { def: "" },
+9 -1
View File
@@ -29,9 +29,11 @@ var SPEC = {
hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, hyprlandLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, hyprlandLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
hyprlandResizeOnBorder: { def: false, onChange: "updateCompositorLayout" },
mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
firstDayOfWeek: { def: -1 }, firstDayOfWeek: { def: -1 },
showWeekNumber: { def: false }, showWeekNumber: { def: false },
@@ -104,6 +106,8 @@ var SPEC = {
controlCenterShowBatteryIcon: { def: false }, controlCenterShowBatteryIcon: { def: false },
controlCenterShowPrinterIcon: { def: false }, controlCenterShowPrinterIcon: { def: false },
controlCenterShowScreenSharingIcon: { def: true }, controlCenterShowScreenSharingIcon: { def: true },
controlCenterShowIdleInhibitorIcon: { def: false },
controlCenterShowDoNotDisturbIcon: { def: false },
showPrivacyButton: { def: true }, showPrivacyButton: { def: true },
privacyShowMicIcon: { def: false }, privacyShowMicIcon: { def: false },
@@ -132,6 +136,7 @@ var SPEC = {
maxWorkspaceIcons: { def: 3 }, maxWorkspaceIcons: { def: 3 },
workspaceAppIconSizeOffset: { def: 0 }, workspaceAppIconSizeOffset: { def: 0 },
groupWorkspaceApps: { def: true }, groupWorkspaceApps: { def: true },
groupActiveWorkspaceApps: { def: false },
workspaceFollowFocus: { def: false }, workspaceFollowFocus: { def: false },
showOccupiedWorkspacesOnly: { def: false }, showOccupiedWorkspacesOnly: { def: false },
reverseScrolling: { def: false }, reverseScrolling: { def: false },
@@ -165,6 +170,7 @@ var SPEC = {
appsDockEnlargePercentage: { def: 125 }, appsDockEnlargePercentage: { def: 125 },
appsDockIconSizePercentage: { def: 100 }, appsDockIconSizePercentage: { def: 100 },
keyboardLayoutNameCompactMode: { def: false }, keyboardLayoutNameCompactMode: { def: false },
keyboardLayoutNameShowIcon: { def: false},
runningAppsCurrentWorkspace: { def: true }, runningAppsCurrentWorkspace: { def: true },
runningAppsGroupByApp: { def: false }, runningAppsGroupByApp: { def: false },
runningAppsCurrentMonitor: { def: false }, runningAppsCurrentMonitor: { def: false },
@@ -182,6 +188,7 @@ var SPEC = {
lockDateFormat: { def: "" }, lockDateFormat: { def: "" },
greeterRememberLastSession: { def: true }, greeterRememberLastSession: { def: true },
greeterRememberLastUser: { def: true }, greeterRememberLastUser: { def: true },
greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" },
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" }, greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" }, greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
greeterWallpaperPath: { def: "" }, greeterWallpaperPath: { def: "" },
@@ -231,7 +238,7 @@ var SPEC = {
qt6ctAvailable: { def: false, persist: false }, qt6ctAvailable: { def: false, persist: false },
gtkAvailable: { def: false, persist: false }, gtkAvailable: { def: false, persist: false },
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" }, cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
availableCursorThemes: { def: ["System Default"], persist: false }, availableCursorThemes: { def: ["System Default"], persist: false },
systemDefaultCursorTheme: { def: "", persist: false }, systemDefaultCursorTheme: { def: "", persist: false },
@@ -587,6 +594,7 @@ function getValidKeys() {
function set(root, key, value, saveFn, hooks) { function set(root, key, value, saveFn, hooks) {
if (!(key in SPEC)) return; if (!(key in SPEC)) return;
if (value === undefined || value === null) value = SPEC[key].def;
root[key] = value; root[key] = value;
var hookName = SPEC[key].onChange; var hookName = SPEC[key].onChange;
if (hookName && hooks && hooks[hookName]) { if (hookName && hooks && hooks[hookName]) {
+38 -2
View File
@@ -328,6 +328,16 @@ Item {
} }
property bool hadRealScreen: true property bool hadRealScreen: true
property var previousRealScreenNames: []
function _getRealScreenNames() {
const names = [];
for (let i = 0; i < Quickshell.screens.length; i++) {
if (Quickshell.screens[i].name.length > 0)
names.push(Quickshell.screens[i].name);
}
return names;
}
function _hasRealScreen() { function _hasRealScreen() {
for (let i = 0; i < Quickshell.screens.length; i++) { for (let i = 0; i < Quickshell.screens.length; i++) {
@@ -353,14 +363,20 @@ Item {
target: Quickshell target: Quickshell
function onScreensChanged() { function onScreensChanged() {
const hasReal = root._hasRealScreen(); const hasReal = root._hasRealScreen();
const currentNames = root._getRealScreenNames();
log.info("Screens changed:", Quickshell.screens.length, log.info("Screens changed:", Quickshell.screens.length,
Quickshell.screens.map(s => "'" + s.name + "'").join(","), Quickshell.screens.map(s => "'" + s.name + "'").join(","),
"hasReal:", hasReal, "hadReal:", root.hadRealScreen); "hasReal:", hasReal, "hadReal:", root.hadRealScreen);
if (!root.hadRealScreen && hasReal) { const fullReconnect = !root.hadRealScreen && hasReal;
log.info("Real screen reappeared after placeholder state, triggering surface recovery"); const partialReconnect = root.previousRealScreenNames.length > 0
&& currentNames.some(name => !root.previousRealScreenNames.includes(name));
if (fullReconnect || partialReconnect) {
log.info("Screen reconnect detected, triggering surface recovery",
"full:", fullReconnect, "partial:", partialReconnect);
root.triggerSurfaceRecovery("screen-reconnect"); root.triggerSurfaceRecovery("screen-reconnect");
} }
root.hadRealScreen = hasReal; root.hadRealScreen = hasReal;
root.previousRealScreenNames = currentNames;
} }
} }
@@ -1124,6 +1140,7 @@ Item {
id: powerMenuModal id: powerMenuModal
onPowerActionRequested: (action, title, message) => { onPowerActionRequested: (action, title, message) => {
PopoutService.closeControlCenter();
switch (action) { switch (action) {
case "logout": case "logout":
SessionService.logout(); SessionService.logout();
@@ -1144,6 +1161,7 @@ Item {
} }
onLockRequested: { onLockRequested: {
PopoutService.closeControlCenter();
lock.activate(); lock.activate();
} }
@@ -1185,6 +1203,24 @@ Item {
} }
} }
LazyLoader {
id: powerProfileModalLoader
active: false
PowerProfileModal {
id: powerProfileModal
Component.onCompleted: {
PopoutService.powerProfileModal = powerProfileModal;
}
}
Component.onCompleted: {
PopoutService.powerProfileModalLoader = powerProfileModalLoader;
}
}
DMSShellIPC { DMSShellIPC {
powerMenuModalLoader: powerMenuModalLoader powerMenuModalLoader: powerMenuModalLoader
processListModalLoader: processListModalLoader processListModalLoader: processListModalLoader
+177 -1
View File
@@ -1,8 +1,10 @@
import QtQuick import QtQuick
import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.Hyprland import Quickshell.Hyprland
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import Quickshell.Services.UPower
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Modules.Settings.DisplayConfig import qs.Modules.Settings.DisplayConfig
@@ -55,6 +57,93 @@ Item {
return currentBar; return currentBar;
} }
readonly property var defaultAppMimeTypes: ({
browser: "x-scheme-handler/https",
fileManager: "inode/directory",
textEditor: "text/plain",
imageViewer: "image/png",
videoPlayer: "video/mp4",
musicPlayer: "audio/mpeg",
pdfReader: "application/pdf",
mail: "x-scheme-handler/mailto",
calendar: "x-scheme-handler/calendar"
})
function launchDesktopId(desktopId, appName) {
if (!desktopId || desktopId.length === 0) {
log.warn("No default app configured for:", appName);
return false;
}
let entry = DesktopEntries.heuristicLookup(desktopId);
if (!entry && desktopId.endsWith(".desktop")) {
entry = DesktopEntries.heuristicLookup(desktopId.slice(0, -8));
}
if (!entry) {
log.warn("Default app desktop entry not found:", desktopId, "for:", appName);
return false;
}
SessionService.launchDesktopEntry(entry);
AppUsageHistoryData.addAppUsage(entry);
return true;
}
function launchDefaultMimeApp(appName, mimeType) {
DMSService.sendRequest("mime.getDefault", {
"mimeType": mimeType
}, response => {
if (response.error) {
log.warn("Failed to resolve default app:", appName, response.error);
return;
}
const result = response.result || {};
root.launchDesktopId(result.desktopId || "", appName);
});
return `DEFAULTAPP_LAUNCH_REQUESTED: ${appName}`;
}
IpcHandler {
function browser(): string {
return root.launchDefaultMimeApp("browser", root.defaultAppMimeTypes.browser);
}
function fileManager(): string {
return root.launchDefaultMimeApp("fileManager", root.defaultAppMimeTypes.fileManager);
}
function textEditor(): string {
return root.launchDefaultMimeApp("textEditor", root.defaultAppMimeTypes.textEditor);
}
function imageViewer(): string {
return root.launchDefaultMimeApp("imageViewer", root.defaultAppMimeTypes.imageViewer);
}
function videoPlayer(): string {
return root.launchDefaultMimeApp("videoPlayer", root.defaultAppMimeTypes.videoPlayer);
}
function musicPlayer(): string {
return root.launchDefaultMimeApp("musicPlayer", root.defaultAppMimeTypes.musicPlayer);
}
function pdfReader(): string {
return root.launchDefaultMimeApp("pdfReader", root.defaultAppMimeTypes.pdfReader);
}
function mail(): string {
return root.launchDefaultMimeApp("mail", root.defaultAppMimeTypes.mail);
}
function calendar(): string {
return root.launchDefaultMimeApp("calendar", root.defaultAppMimeTypes.calendar);
}
target: "defaultApp"
}
IpcHandler { IpcHandler {
function open() { function open() {
root.powerMenuModalLoader.active = true; root.powerMenuModalLoader.active = true;
@@ -161,6 +250,21 @@ Item {
target: "control-center" target: "control-center"
} }
IpcHandler {
// Screenshot region-select handshake
function begin(): string {
PopoutManager.screenshotActive = true;
return "SCREENSHOT_MODE_ON";
}
function end(): string {
PopoutManager.screenshotActive = false;
return "SCREENSHOT_MODE_OFF";
}
target: "screenshot"
}
IpcHandler { IpcHandler {
function resolveTabIndex(tab: string): int { function resolveTabIndex(tab: string): int {
switch ((tab || "").toLowerCase()) { switch ((tab || "").toLowerCase()) {
@@ -236,6 +340,9 @@ Item {
if (CompositorService.isDwl && DwlService.activeOutput) { if (CompositorService.isDwl && DwlService.activeOutput) {
return DwlService.activeOutput; return DwlService.activeOutput;
} }
if (CompositorService.isMango && MangoService.activeOutput) {
return MangoService.activeOutput;
}
return ""; return "";
} }
@@ -840,7 +947,7 @@ Item {
function tabs(): string { function tabs(): string {
if (!PopoutService.settingsModal) if (!PopoutService.settingsModal)
return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_widgets\nworkspaces\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nprinters\nlock_screen\npower_sleep\nplugins\nabout"; return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nprinters\nlock_screen\npower_sleep\nplugins\nabout";
var modal = PopoutService.settingsModal; var modal = PopoutService.settingsModal;
var ids = []; var ids = [];
var structure = modal.sidebar?.categoryStructure ?? []; var structure = modal.sidebar?.categoryStructure ?? [];
@@ -1875,4 +1982,73 @@ Item {
target: "tray" target: "tray"
} }
IpcHandler {
function open(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
PopoutService.openPowerProfileModal();
return "POWERPROFILE_OPEN_SUCCESS";
}
function close(): string {
PopoutService.closePowerProfileModal();
return "POWERPROFILE_CLOSE_SUCCESS";
}
function toggle(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
PopoutService.togglePowerProfileModal();
return "POWERPROFILE_TOGGLE_SUCCESS";
}
function list(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
return PowerProfileWatcher.availableProfiles.map(profile => PowerProfileWatcher.profileSlug(profile)).join("\n");
}
function status(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
return PowerProfileWatcher.profileSlug(PowerProfiles.profile);
}
function set(profile: string): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
if (!profile)
return "ERROR: No profile specified";
const parsed = PowerProfileWatcher.parseProfileSlug(profile);
if (parsed === -1)
return "ERROR: Unknown power profile. Supported options: power-saver, balanced, performance";
if (parsed === PowerProfile.Performance && !PowerProfiles.hasPerformanceProfile)
return "ERROR: Performance profile not supported by hardware";
if (!PowerProfileWatcher.applyProfile(parsed))
return "ERROR: Failed to set power profile";
return "POWERPROFILE_SET_SUCCESS";
}
function cycle(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
if (!PowerProfileWatcher.cycleProfile())
return "ERROR: Failed to set power profile";
return "POWERPROFILE_CYCLE_SUCCESS";
}
target: "powerprofile"
}
} }
@@ -26,7 +26,8 @@ Item {
ClipboardHeader { ClipboardHeader {
id: header id: header
width: parent.width width: parent.width
totalCount: modal.totalCount recentsCount: modal.unpinnedEntries.length
savedCount: modal.pinnedEntries.length
showKeyboardHints: modal.showKeyboardHints showKeyboardHints: modal.showKeyboardHints
activeTab: modal.activeTab activeTab: modal.activeTab
pinnedCount: modal.pinnedCount pinnedCount: modal.pinnedCount
@@ -65,15 +66,6 @@ Item {
forceActiveFocus(); forceActiveFocus();
}); });
} }
Connections {
target: modal
function onOpened() {
Qt.callLater(function () {
searchField.forceActiveFocus();
});
}
}
} }
} }
@@ -108,6 +100,20 @@ Item {
pressDelay: 0 pressDelay: 0
flickableDirection: Flickable.VerticalFlick flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: clipboardListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) { function ensureVisible(index) {
if (index < 0 || index >= count) { if (index < 0 || index >= count) {
return; return;
@@ -145,6 +151,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
} }
} }
@@ -167,6 +174,20 @@ Item {
pressDelay: 0 pressDelay: 0
flickableDirection: Flickable.VerticalFlick flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: savedListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) { function ensureVisible(index) {
if (index < 0 || index >= count) { if (index < 0 || index >= count) {
return; return;
@@ -204,6 +225,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
} }
} }
@@ -0,0 +1,530 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
required property var modal
property var keyController: null
property var entry: null
property string editorText: ""
function decodeEntryData(data) {
if (!data) {
return "";
}
if (typeof data !== "string") {
return String(data);
}
const sanitized = data.replace(/\s+/g, "");
if (sanitized.length === 0) {
return "";
}
try {
const decoded = Qt.atob(sanitized);
if (!decoded) {
return data;
}
let binary = "";
if (typeof decoded === "string") {
// Pre-6.11 Qt.atob returns a binary string directly
binary = decoded;
} else {
// Qt 6.11+ Qt.atob returns an ArrayBuffer convert to avoid O(n²) concat/stack limits
const bytes = new Uint8Array(decoded);
const chunkSize = 8192;
const chunks = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)));
}
binary = chunks.join("");
}
if (!binary) {
return data;
}
try {
return decodeURIComponent(escape(binary));
} catch (e) {
return binary;
}
} catch (e) {
return data;
}
}
function setEntry(newEntry) {
entry = newEntry;
editorText = newEntry?.text ?? newEntry?.preview ?? "";
if (editField) {
editField.text = editorText;
}
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
editField.cursorPosition = editField.text.length;
}
});
if (!newEntry || newEntry.isImage) {
return;
}
const requestedId = newEntry.id;
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error) {
return;
}
if (!root.entry || root.entry.id !== requestedId) {
return;
}
if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result;
let fullText = "";
if (result?.data) {
fullText = root.decodeEntryData(result.data);
} else {
fullText = result?.preview ?? "";
}
if (!fullText || fullText.length === 0) {
return;
}
root.editorText = fullText;
if (editField) {
if (fullText.length > 50000) {
Qt.callLater(function () {
if (editField) {
editField.text = fullText;
editField.cursorPosition = fullText.length;
}
});
} else {
editField.text = fullText;
editField.cursorPosition = fullText.length;
}
}
});
}
function saveEntry(action) {
const saveAction = action ?? "history";
DMSService.sendRequest("clipboard.copy", {
"text": root.editorText
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
}
if (saveAction === "history") {
modal.mode = "history";
Qt.callLater(function () {
ClipboardService.reset();
ClipboardService.refresh();
if (keyController) {
keyController.reset();
}
});
return;
}
if (saveAction === "close") {
modal.hide();
return;
}
if (saveAction === "paste") {
ClipboardService.pasteClipboard(modal.hide);
}
});
}
function positionSaveMenu() {
saveMenu.width = Math.max(saveMenuColumn.implicitWidth + saveMenu.padding * 2, saveButton.width);
const pos = saveButton.mapToItem(Overlay.overlay, 0, 0);
const popupW = saveMenu.width;
const popupH = saveMenu.height;
const overlayW = Overlay.overlay.width;
const overlayH = Overlay.overlay.height;
let x = pos.x + (saveButton.width - popupW) / 2;
let y = pos.y + saveButton.height + 4;
if (y + popupH > overlayH) {
y = pos.y - popupH - 4;
}
x = Math.max(8, Math.min(x, overlayW - popupW - 8));
y = Math.max(8, y);
saveMenu.x = x;
saveMenu.y = y;
}
function toggleSaveMenu() {
if (saveMenu.visible) {
saveMenu.close();
return;
}
saveMenu.open();
positionSaveMenu();
Qt.callLater(positionSaveMenu);
}
Shortcut {
sequences: ["Escape"]
enabled: modal.mode === "editor"
onActivated: modal.mode = "history"
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
Item {
id: editorHeader
width: parent.width
height: ClipboardConstants.headerHeight
DankActionButton {
iconName: "arrow_back"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
onClicked: modal.mode = "history"
}
StyledText {
text: I18n.tr("Edit Clipboard")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.centerIn: parent
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onClicked: modal.mode = "history"
}
}
StyledRect {
id: editFieldContainer
width: parent.width
height: Math.max(Theme.fontSizeMedium * 8, parent.height - editorHeader.height - editorActions.height - Theme.spacingM * 2)
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.color: editField.activeFocus ? Theme.primary : Theme.outlineMedium
border.width: editField.activeFocus ? 2 : 1
clip: true
DankIcon {
id: editIcon
name: "edit"
size: Theme.iconSize
color: editField.activeFocus ? Theme.primary : Theme.surfaceVariantText
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: Theme.spacingM
}
DankFlickable {
id: editScroll
anchors.left: editIcon.right
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.rightMargin: Theme.spacingM
anchors.topMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
clip: true
contentWidth: width
contentHeight: editField.height
TextEdit {
id: editField
width: editScroll.width
height: Math.max(editScroll.height, contentHeight)
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
wrapMode: TextEdit.Wrap
selectByMouse: true
onTextChanged: root.editorText = text
Keys.onPressed: function (event) {
const hasCtrl = (event.modifiers & Qt.ControlModifier) !== 0;
const hasShift = (event.modifiers & Qt.ShiftModifier) !== 0;
if (hasCtrl && event.key === Qt.Key_S) {
root.saveEntry(hasShift ? "close" : "history");
event.accepted = true;
return;
}
if (hasCtrl && hasShift && event.key === Qt.Key_V) {
root.saveEntry("paste");
event.accepted = true;
return;
}
}
}
}
StyledText {
text: I18n.tr("Edit clipboard text")
font.pixelSize: Theme.fontSizeMedium
color: Theme.outlineButton
anchors.left: editScroll.left
anchors.right: editScroll.right
anchors.top: editScroll.top
anchors.bottom: editScroll.bottom
visible: editField.text.length === 0 && !editField.activeFocus
wrapMode: Text.WordWrap
}
}
Row {
id: editorActions
width: parent.width
spacing: Theme.spacingS
Item {
id: buttonSpacer
width: Math.max(0, parent.width - cancelButton.width - saveButton.width - Theme.spacingS)
height: 1
}
DankButton {
id: cancelButton
text: I18n.tr("Cancel")
backgroundColor: Theme.surfaceContainerHigh
textColor: Theme.surfaceText
onClicked: modal.mode = "history"
}
Item {
id: saveButton
readonly property int buttonHeight: cancelButton.buttonHeight
readonly property int arrowWidth: Theme.iconSizeLarge
width: cancelButton.width
height: buttonHeight
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.primary
}
Item {
id: saveMainArea
anchors.left: parent.left
anchors.right: saveArrowArea.left
anchors.top: parent.top
anchors.bottom: parent.bottom
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.onPrimary
anchors.centerIn: saveMainArea
}
Item {
id: saveArrowArea
width: saveButton.arrowWidth
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
Rectangle {
width: 1
height: parent.height - cancelButton.horizontalPadding
color: Theme.withAlpha(Theme.onPrimary, 0.2)
anchors.right: saveArrowArea.left
anchors.verticalCenter: parent.verticalCenter
}
DankIcon {
name: saveMenu.visible ? "expand_less" : "expand_more"
size: Theme.iconSizeSmall
color: Theme.onPrimary
anchors.centerIn: saveArrowArea
}
StateLayer {
z: 1
anchors.fill: saveMainArea
stateColor: Theme.onPrimary
onClicked: root.saveEntry("history")
}
StateLayer {
z: 1
anchors.fill: saveArrowArea
stateColor: Theme.onPrimary
onClicked: root.toggleSaveMenu()
}
}
}
Popup {
id: saveMenu
parent: Overlay.overlay
padding: Theme.spacingM
modal: true
dim: false
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: StyledRect {
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Theme.outlineMedium
border.width: 1
}
contentItem: Column {
id: saveMenuColumn
spacing: Theme.spacingXS
StyledRect {
implicitWidth: saveMenuRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuSaveArea.containsMouse ? Theme.surfaceVariant : "transparent"
Row {
id: saveMenuRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "save"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuSaveArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
saveMenu.close();
root.saveEntry("history");
}
}
}
StyledRect {
implicitWidth: saveMenuCloseRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuCloseRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuCloseArea.containsMouse ? Theme.surfaceVariant : "transparent"
Row {
id: saveMenuCloseRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "close"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save and close")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
saveMenu.close();
root.saveEntry("close");
}
}
}
StyledRect {
implicitWidth: saveMenuPasteRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : "transparent"
opacity: modal.wtypeAvailable ? 1 : 0.5
Row {
id: saveMenuPasteRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "content_paste"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save and paste")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuPasteArea
anchors.fill: parent
hoverEnabled: true
enabled: modal.wtypeAvailable
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
saveMenu.close();
root.saveEntry("paste");
}
}
}
}
}
}
}
+19 -2
View File
@@ -17,6 +17,7 @@ Rectangle {
signal deleteRequested signal deleteRequested
signal pinRequested signal pinRequested
signal unpinRequested signal unpinRequested
signal editRequested
readonly property string entryType: modal ? modal.getEntryType(entry) : "text" readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
@@ -70,6 +71,19 @@ Rectangle {
onClicked: entry.pinned ? unpinRequested() : pinRequested() onClicked: entry.pinned ? unpinRequested() : pinRequested()
} }
DankActionButton {
iconName: "edit"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
onClicked: {
if (entryType === "image") {
return;
}
editRequested();
}
}
DankActionButton { DankActionButton {
iconName: "close" iconName: "close"
iconSize: Theme.iconSize - 6 iconSize: Theme.iconSize - 6
@@ -142,8 +156,11 @@ Rectangle {
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.left: parent.left
anchors.rightMargin: 80 anchors.right: actionButtons.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.bottom: parent.bottom
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: mouse => { onPressed: mouse => {
@@ -6,7 +6,8 @@ import qs.Modals.Clipboard
Item { Item {
id: header id: header
property int totalCount: 0 property int recentsCount: 0
property int savedCount: 0
property bool showKeyboardHints: false property bool showKeyboardHints: false
property string activeTab: "recents" property string activeTab: "recents"
property int pinnedCount: 0 property int pinnedCount: 0
@@ -31,7 +32,7 @@ Item {
} }
StyledText { StyledText {
text: I18n.tr("Clipboard History") + ` (${totalCount})` text: (header.activeTab === "saved" ? I18n.tr("Clipboard Saved") : I18n.tr("Clipboard History")) + ` (${header.activeTab === "saved" ? header.savedCount : header.recentsCount})`
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText color: Theme.surfaceText
font.weight: Font.Medium font.weight: Font.Medium
@@ -48,7 +49,8 @@ Item {
iconName: "push_pin" iconName: "push_pin"
iconSize: Theme.iconSize - 4 iconSize: Theme.iconSize - 4
iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText
visible: header.pinnedCount > 0 backgroundColor: header.activeTab === "saved" ? Theme.primarySelected : "transparent"
visible: header.pinnedCount > 0 || header.activeTab === "saved"
tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved") tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved")
onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "saved") onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "saved")
} }
@@ -0,0 +1,219 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
FocusScope {
id: root
property var clearConfirmDialog: null
property string activeTab: "recents"
property bool showKeyboardHints: false
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
property string mode: "history"
property string searchText: ClipboardService.searchText
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
readonly property var modalFocusScope: root
property alias searchField: historyContent.searchField
property alias editorView: editorView
property alias keyboardController: keyboardController
signal closeRequested
signal instantCloseRequested
onActiveTabChanged: {
if (activeTab === "saved" && pinnedCount === 0) {
activeTab = "recents";
return;
}
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = false;
}
onPinnedCountChanged: {
if (activeTab === "saved" && pinnedCount === 0) {
activeTab = "recents";
}
}
onSearchTextChanged: ClipboardService.searchText = searchText
function hide() {
closeRequested();
}
function pasteSelected() {
ClipboardService.pasteSelected(() => root.instantCloseRequested());
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, () => root.closeRequested());
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function refreshClipboard() {
ClipboardService.refresh();
}
function editEntry(entry) {
if (!entry || entry.isImage) {
return;
}
editorView.setEntry(entry);
mode = "editor";
}
function resetState() {
activeImageLoads = 0;
mode = "history";
ClipboardService.reset();
keyboardController.reset();
}
focus: true
Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
ClipboardKeyboardController {
id: keyboardController
modal: root
}
Item {
id: historyView
anchors.fill: parent
opacity: 1
scale: 1
visible: opacity > 0.01
enabled: root.mode === "history"
ClipboardContent {
id: historyContent
anchors.fill: parent
modal: root
clearConfirmDialog: root.clearConfirmDialog
}
}
ClipboardEditor {
id: editorView
anchors.fill: parent
opacity: 0
scale: 0.98
visible: opacity > 0.01
enabled: root.mode === "editor"
focus: root.mode === "editor"
modal: root
keyController: keyboardController
}
states: [
State {
name: "history"
when: root.mode === "history"
PropertyChanges {
target: historyView
opacity: 1
scale: 1
}
PropertyChanges {
target: editorView
opacity: 0
scale: 0.98
}
},
State {
name: "editor"
when: root.mode === "editor"
PropertyChanges {
target: historyView
opacity: 0
scale: 0.98
}
PropertyChanges {
target: editorView
opacity: 1
scale: 1
}
}
]
transitions: [
Transition {
from: "history"
to: "editor"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
},
Transition {
from: "editor"
to: "history"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
}
@@ -17,61 +17,28 @@ DankModal {
active: clipboardHistoryModal.useHyprlandFocusGrab && clipboardHistoryModal.shouldHaveFocus active: clipboardHistoryModal.useHyprlandFocusGrab && clipboardHistoryModal.shouldHaveFocus
} }
property string activeTab: "recents"
onActiveTabChanged: {
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = false;
}
property bool showKeyboardHints: false
property Component clipboardContent
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
property string searchText: ClipboardService.searchText
onSearchTextChanged: ClipboardService.searchText = searchText
Ref {
service: ClipboardService
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function pasteSelected() {
ClipboardService.pasteSelected(instantClose);
}
function toggle() { function toggle() {
if (shouldBeVisible) { if (shouldBeVisible) {
hide(); hide();
} else { return;
show();
} }
show();
} }
function show() { function show() {
open(); open();
activeImageLoads = 0;
shouldHaveFocus = true; shouldHaveFocus = true;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () { Qt.callLater(function () {
if (clipboardAvailable) { if (contentLoader.item) {
contentLoader.item.resetState();
}
if (clipboardHistoryModal.clipboardAvailable) {
if (Theme.isConnectedEffect) { if (Theme.isConnectedEffect) {
Qt.callLater(() => { Qt.callLater(() => {
if (clipboardHistoryModal.shouldBeVisible) if (clipboardHistoryModal.shouldBeVisible) {
ClipboardService.refresh(); ClipboardService.refresh();
}
}); });
} else { } else {
ClipboardService.refresh(); ClipboardService.refresh();
@@ -89,46 +56,12 @@ DankModal {
} }
onDialogClosed: { onDialogClosed: {
activeImageLoads = 0; if (contentLoader.item) {
ClipboardService.reset(); contentLoader.item.resetState();
keyboardController.reset(); }
} }
function refreshClipboard() { readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
ClipboardService.refresh();
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, hide);
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
visible: false visible: false
modalWidth: ClipboardConstants.modalWidth modalWidth: ClipboardConstants.modalWidth
@@ -138,15 +71,11 @@ DankModal {
borderColor: Theme.outlineMedium borderColor: Theme.outlineMedium
borderWidth: 1 borderWidth: 1
enableShadow: true enableShadow: true
closeOnEscapeKey: (contentLoader.item?.mode ?? "history") !== "editor"
onBackgroundClicked: hide() onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
content: clipboardContent
ClipboardKeyboardController { Ref {
id: keyboardController service: ClipboardService
modal: clipboardHistoryModal
} }
ConfirmModal { ConfirmModal {
@@ -171,12 +100,11 @@ DankModal {
} }
} }
property var confirmDialog: clearConfirmDialog content: Component {
ClipboardHistoryContent {
clipboardContent: Component { clearConfirmDialog: clearConfirmDialog
ClipboardContent { onCloseRequested: clipboardHistoryModal.hide()
modal: clipboardHistoryModal onInstantCloseRequested: clipboardHistoryModal.instantClose()
clearConfirmDialog: clipboardHistoryModal.confirmDialog
} }
} }
} }
@@ -15,47 +15,20 @@ DankPopout {
property var parentWidget: null property var parentWidget: null
property var triggerScreen: null property var triggerScreen: null
property string activeTab: "recents" property string activeTab: "recents"
property bool showKeyboardHints: false
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries readonly property var confirmDialog: clearConfirmDialog
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
property string searchText: ClipboardService.searchText
onSearchTextChanged: ClipboardService.searchText = searchText
readonly property var modalFocusScope: contentLoader.item ?? null readonly property var modalFocusScope: contentLoader.item ?? null
Ref {
service: ClipboardService
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function pasteSelected() {
ClipboardService.pasteSelected(instantClose);
}
function instantClose() {
close();
}
function show() { function show() {
open(); open();
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () { Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) { if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = ""; contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus(); contentLoader.item.searchField.forceActiveFocus();
@@ -65,47 +38,12 @@ DankPopout {
function hide() { function hide() {
close(); close();
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
}
function refreshClipboard() {
ClipboardService.refresh();
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, hide);
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
} }
function clearAll() { function clearAll() {
ClipboardService.clearAll(); ClipboardService.clearAll();
} }
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
popupWidth: ClipboardConstants.popoutWidth popupWidth: ClipboardConstants.popoutWidth
popupHeight: ClipboardConstants.popoutHeight popupHeight: ClipboardConstants.popoutHeight
triggerWidth: 55 triggerWidth: 55
@@ -117,20 +55,25 @@ DankPopout {
onBackgroundClicked: hide() onBackgroundClicked: hide()
onShouldBeVisibleChanged: { onShouldBeVisibleChanged: {
if (!shouldBeVisible) if (!shouldBeVisible) {
return; return;
}
if (clipboardAvailable) { if (clipboardAvailable) {
if (Theme.isConnectedEffect) { if (Theme.isConnectedEffect) {
Qt.callLater(() => { Qt.callLater(() => {
if (root.shouldBeVisible) if (root.shouldBeVisible) {
ClipboardService.refresh(); ClipboardService.refresh();
}
}); });
} else { } else {
ClipboardService.refresh(); ClipboardService.refresh();
} }
} }
keyboardController.reset();
Qt.callLater(function () { Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) { if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = ""; contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus(); contentLoader.item.searchField.forceActiveFocus();
@@ -139,14 +82,13 @@ DankPopout {
} }
onPopoutClosed: { onPopoutClosed: {
activeImageLoads = 0; if (contentLoader.item) {
ClipboardService.reset(); contentLoader.item.resetState();
keyboardController.reset(); }
} }
ClipboardKeyboardController { Ref {
id: keyboardController service: ClipboardService
modal: root
} }
ConfirmModal { ConfirmModal {
@@ -155,48 +97,20 @@ DankPopout {
confirmButtonColor: Theme.primary confirmButtonColor: Theme.primary
} }
property var confirmDialog: clearConfirmDialog
content: Component { content: Component {
FocusScope { ClipboardHistoryContent {
id: contentFocusScope
LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
focus: true clearConfirmDialog: clearConfirmDialog
onCloseRequested: root.hide()
property alias searchField: clipboardContentItem.searchField onInstantCloseRequested: root.close()
Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
Component.onCompleted: { Component.onCompleted: {
if (root.shouldBeVisible) activeTab = root.activeTab;
if (root.shouldBeVisible) {
forceActiveFocus(); forceActiveFocus();
} }
Connections {
target: root
function onShouldBeVisibleChanged() {
if (root.shouldBeVisible) {
Qt.callLater(() => contentFocusScope.forceActiveFocus());
}
}
function onOpened() {
Qt.callLater(() => {
if (clipboardContentItem.searchField) {
clipboardContentItem.searchField.forceActiveFocus();
}
});
}
}
ClipboardContent {
id: clipboardContentItem
modal: root
clearConfirmDialog: root.confirmDialog
} }
} }
} }
@@ -66,7 +66,24 @@ QtObject {
} }
} }
function editSelected() {
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
if (!entries || entries.length === 0) {
return;
}
const index = ClipboardService.selectedIndex >= 0 && ClipboardService.selectedIndex < entries.length ? ClipboardService.selectedIndex : 0;
modal.editEntry(entries[index]);
}
function handleKey(event) { function handleKey(event) {
if (modal.mode === "editor") {
if (event.key === Qt.Key_Escape) {
modal.mode = "history";
event.accepted = true;
}
return;
}
switch (event.key) { switch (event.key) {
case Qt.Key_Escape: case Qt.Key_Escape:
if (ClipboardService.keyboardNavigationActive) { if (ClipboardService.keyboardNavigationActive) {
@@ -152,6 +169,10 @@ QtObject {
event.accepted = true; event.accepted = true;
} }
return; return;
case Qt.Key_E:
editSelected();
event.accepted = true;
return;
} }
} }
@@ -10,7 +10,7 @@ Rectangle {
readonly property string hintsText: { readonly property string hintsText: {
if (!wtypeAvailable) if (!wtypeAvailable)
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close"); return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close"); return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close");
} }
height: ClipboardConstants.keyboardHintsHeight height: ClipboardConstants.keyboardHintsHeight
@@ -22,13 +22,17 @@ Rectangle {
z: 100 z: 100
Column { Column {
width: parent.width - Theme.spacingL * 2
anchors.centerIn: parent anchors.centerIn: parent
spacing: 2 spacing: 2
StyledText { StyledText {
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help") text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
@@ -36,6 +40,9 @@ Rectangle {
text: keyboardHints.hintsText text: keyboardHints.hintsText
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
} }
@@ -13,6 +13,7 @@ Item {
required property var modal required property var modal
required property var listView required property var listView
required property int itemIndex required property int itemIndex
property bool disposed: false
Image { Image {
id: thumbnailImage id: thumbnailImage
@@ -20,6 +21,13 @@ Item {
property bool isVisible: false property bool isVisible: false
property string cachedImageData: "" property string cachedImageData: ""
property bool loadQueued: false property bool loadQueued: false
property bool activeLoad: false
property bool completed: false
property int loadGeneration: 0
property var activeEntryId: null
property var activeRequest: null
property var currentEntryId: entry && entry.id !== undefined ? entry.id : null
property string currentEntryType: entryType
anchors.fill: parent anchors.fill: parent
source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : "" source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : ""
@@ -31,29 +39,119 @@ Item {
sourceSize.width: 128 sourceSize.width: 128
sourceSize.height: 128 sourceSize.height: 128
onCurrentEntryIdChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
onCurrentEntryTypeChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
function hasValidEntryId() {
return entry && entry.id !== undefined && entry.id !== null;
}
function releaseActiveLoad() {
if (!thumbnailImage.activeLoad) {
return;
}
thumbnailImage.activeLoad = false;
if (modal && modal.activeImageLoads > 0) {
modal.activeImageLoads--;
}
}
function finishLoad(request) {
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
if (!request || thumbnailImage.activeRequest === request) {
thumbnailImage.activeRequest = null;
}
thumbnailImage.releaseActiveLoad();
}
function cancelLoad() {
if (thumbnailImage.activeRequest) {
thumbnailImage.activeRequest.cancelled = true;
thumbnailImage.activeRequest = null;
}
retryTimer.stop();
visibilityTimer.stop();
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
thumbnailImage.releaseActiveLoad();
}
function resetForEntry() {
thumbnailImage.loadGeneration++;
thumbnailImage.cachedImageData = "";
thumbnailImage.isVisible = false;
thumbnailImage.cancelLoad();
Qt.callLater(function () {
if (thumbnail.disposed) {
return;
}
thumbnailImage.checkVisibility();
});
}
function startLoad() {
if (!modal) {
thumbnailImage.loadQueued = false;
return;
}
modal.activeImageLoads++;
thumbnailImage.activeLoad = true;
thumbnailImage.loadImage();
}
function tryLoadImage() { function tryLoadImage() {
if (thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData) { if (thumbnail.disposed || thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData || !thumbnailImage.hasValidEntryId()) {
return; return;
} }
thumbnailImage.loadQueued = true; thumbnailImage.loadQueued = true;
if (modal.activeImageLoads < modal.maxConcurrentLoads) { if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++; thumbnailImage.startLoad();
thumbnailImage.loadImage();
} else { } else {
retryTimer.restart(); retryTimer.restart();
} }
} }
function loadImage() { function loadImage() {
if (!thumbnailImage.hasValidEntryId()) {
thumbnailImage.finishLoad();
return;
}
const requestedId = entry.id;
const generation = thumbnailImage.loadGeneration;
const request = {
"cancelled": false
};
thumbnailImage.activeEntryId = requestedId;
thumbnailImage.activeRequest = request;
DMSService.sendRequest("clipboard.getEntry", { DMSService.sendRequest("clipboard.getEntry", {
"id": entry.id "id": requestedId
}, function (response) { }, function (response) {
thumbnailImage.loadQueued = false; if (request.cancelled) {
if (modal.activeImageLoads > 0) { return;
modal.activeImageLoads--; }
if (thumbnail.disposed || generation !== thumbnailImage.loadGeneration || thumbnailImage.activeRequest !== request || thumbnailImage.activeEntryId !== requestedId) {
return;
}
thumbnailImage.finishLoad(request);
if (!entry || entry.id !== requestedId || entryType !== "image") {
return;
} }
if (response.error) { if (response.error) {
log.warn("Failed to load image:", entry.id); log.warn("Failed to load image:", requestedId);
return;
}
if (!response.result) {
ClipboardService.refresh();
return; return;
} }
const data = response.result?.data; const data = response.result?.data;
@@ -70,9 +168,8 @@ Item {
if (!thumbnailImage.loadQueued) { if (!thumbnailImage.loadQueued) {
return; return;
} }
if (modal.activeImageLoads < modal.maxConcurrentLoads) { if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++; thumbnailImage.startLoad();
thumbnailImage.loadImage();
} else { } else {
retryTimer.restart(); retryTimer.restart();
} }
@@ -80,7 +177,8 @@ Item {
} }
Component.onCompleted: { Component.onCompleted: {
if (entryType !== "image" || listView.height <= 0) { thumbnailImage.completed = true;
if (entryType !== "image" || listView.height <= 0 || !thumbnailImage.hasValidEntryId()) {
return; return;
} }
@@ -94,6 +192,11 @@ Item {
} }
} }
Component.onDestruction: {
thumbnail.disposed = true;
thumbnailImage.cancelLoad();
}
Timer { Timer {
id: visibilityTimer id: visibilityTimer
interval: 100 interval: 100
@@ -101,7 +204,7 @@ Item {
} }
function checkVisibility() { function checkVisibility() {
if (entryType !== "image" || listView.height <= 0 || isVisible) { if (thumbnail.disposed || entryType !== "image" || listView.height <= 0 || isVisible || !thumbnailImage.hasValidEntryId()) {
return; return;
} }
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing); const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
+63 -70
View File
@@ -105,52 +105,65 @@ Item {
property bool animationsEnabled: true property bool animationsEnabled: true
property string _chromeClaimId: ""
property bool _fullSyncPending: false property bool _fullSyncPending: false
function _nextChromeClaimId() {
return layerNamespace + ":modal:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000);
}
function _currentScreenName() { function _currentScreenName() {
return effectiveScreen ? effectiveScreen.name : ""; return effectiveScreen ? effectiveScreen.name : "";
} }
function _publishModalChromeState(isClaim) { ConnectedModalChrome {
const screenName = _currentScreenName(); id: modalChrome
if (!screenName) modalHandle: root.modalHandle
return; claimPrefix: root.layerNamespace + ":modal"
surfaceKind: "modal"
screenName: root._currentScreenName()
enabled: root.frameOwnsConnectedChrome
active: root.shouldBeVisible
presented: root.shouldBeVisible || contentWindow.visible
dockBlocked: root._dockBlocksEmergence
dockSide: root.resolvedConnectedBarSide
onRecoveryRequested: root._queueFullSync()
}
function _publishModalChromeState() {
const presented = shouldBeVisible || contentWindow.visible;
const phase = !presented ? "hidden" : (!shouldBeVisible && contentWindow.visible ? "closing" : (!contentWindow.visible ? "opening" : "open"));
const bodyRect = {
"x": alignedX,
"y": alignedY,
"width": alignedWidth,
"height": alignedHeight
};
const animationOffset = {
"x": modalContainer ? modalContainer.animX : 0,
"y": modalContainer ? modalContainer.animY : 0
};
const state = { const state = {
"visible": shouldBeVisible || contentWindow.visible, "kind": "modal",
"screenName": root._currentScreenName(),
"phase": phase,
"visible": presented,
"presented": presented,
"barSide": resolvedConnectedBarSide, "barSide": resolvedConnectedBarSide,
"bodyRect": bodyRect,
"animationOffset": animationOffset,
"scale": 1,
"opacity": Theme.connectedSurfaceColor.a,
"bodyX": alignedX, "bodyX": alignedX,
"bodyY": alignedY, "bodyY": alignedY,
"bodyW": alignedWidth, "bodyW": alignedWidth,
"bodyH": alignedHeight, "bodyH": alignedHeight,
"animX": modalContainer ? modalContainer.animX : 0, "animX": animationOffset.x,
"animY": modalContainer ? modalContainer.animY : 0, "animY": animationOffset.y,
"omitStartConnector": false, "omitStartConnector": false,
"omitEndConnector": false "omitEndConnector": false,
"dockRetractSide": root._dockBlocksEmergence ? resolvedConnectedBarSide : ""
}; };
if (isClaim) return modalChrome.publish(state);
ConnectedModeState.claimModalState(screenName, state, _chromeClaimId);
else
ConnectedModeState.updateModalState(screenName, state, _chromeClaimId);
} }
function _syncModalChromeState() { function _syncModalChromeState() {
if (!frameOwnsConnectedChrome) { _publishModalChromeState();
_releaseModalChrome();
return;
}
const isClaim = !_chromeClaimId;
if (!_chromeClaimId)
_chromeClaimId = _nextChromeClaimId();
_publishModalChromeState(isClaim);
if (_dockBlocksEmergence && (shouldBeVisible || contentWindow.visible))
ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide);
else
ConnectedModeState.releaseDockRetract(_chromeClaimId);
} }
property bool _animSyncQueued: false property bool _animSyncQueued: false
@@ -187,32 +200,21 @@ Item {
} }
function _syncModalAnim() { function _syncModalAnim() {
if (!frameOwnsConnectedChrome || !_chromeClaimId) if (!frameOwnsConnectedChrome)
return; return;
const screenName = _currentScreenName(); if (!modalContainer)
if (!screenName || !modalContainer)
return; return;
ConnectedModeState.setModalAnim(screenName, modalContainer.animX, modalContainer.animY, _chromeClaimId); modalChrome.updateAnim(modalContainer.animX, modalContainer.animY);
} }
function _syncModalBody() { function _syncModalBody() {
if (!frameOwnsConnectedChrome || !_chromeClaimId) if (!frameOwnsConnectedChrome)
return; return;
const screenName = _currentScreenName(); modalChrome.updateBody(alignedX, alignedY, alignedWidth, alignedHeight);
if (!screenName)
return;
ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight, _chromeClaimId);
} }
function _releaseModalChrome() { function _releaseModalChrome() {
if (!_chromeClaimId) modalChrome.release();
return;
ConnectedModeState.releaseDockRetract(_chromeClaimId);
const claimId = _chromeClaimId;
_chromeClaimId = "";
const screenName = _currentScreenName();
if (screenName)
ConnectedModeState.clearModalState(screenName, claimId);
} }
onFrameOwnsConnectedChromeChanged: _syncModalChromeState() onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
@@ -223,8 +225,6 @@ Item {
onAlignedWidthChanged: _queueBodySync() onAlignedWidthChanged: _queueBodySync()
onAlignedHeightChanged: _queueBodySync() onAlignedHeightChanged: _queueBodySync()
Component.onDestruction: _releaseModalChrome()
Connections { Connections {
target: contentWindow target: contentWindow
function onVisibleChanged() { function onVisibleChanged() {
@@ -248,12 +248,12 @@ Item {
clickCatcher.screen = focusedScreen; clickCatcher.screen = focusedScreen;
} }
ModalManager.openModal(modalHandle);
if (Theme.isDirectionalEffect || root.useBackground) { if (Theme.isDirectionalEffect || root.useBackground) {
if (!useSingleWindow) if (!useSingleWindow)
clickCatcher.visible = true; clickCatcher.visible = true;
contentWindow.visible = true; contentWindow.visible = true;
} }
ModalManager.openModal(modalHandle);
Qt.callLater(() => { Qt.callLater(() => {
animationsEnabled = true; animationsEnabled = true;
@@ -262,6 +262,7 @@ Item {
clickCatcher.visible = true; clickCatcher.visible = true;
if (!contentWindow.visible) if (!contentWindow.visible)
contentWindow.visible = true; contentWindow.visible = true;
opened();
shouldHaveFocus = false; shouldHaveFocus = false;
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
}); });
@@ -316,8 +317,12 @@ Item {
break; break;
} }
} }
if (screenStillExists) if (screenStillExists) {
if (root.shouldBeVisible)
root._queueFullSync();
return; return;
}
root._releaseModalChrome();
const newScreen = CompositorService.getFocusedScreen(); const newScreen = CompositorService.getFocusedScreen();
if (newScreen) { if (newScreen) {
contentWindow.screen = newScreen; contentWindow.screen = newScreen;
@@ -497,22 +502,12 @@ Item {
} }
WlrLayershell.namespace: root.layerNamespace WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: { WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
if (root.useOverlayLayer) "allow": ["top", "overlay"],
return WlrLayershell.Overlay; "invalidLayer": WlrLayer.Top,
switch (Quickshell.env("DMS_MODAL_LAYER")) { "label": "modals",
case "bottom": "error": true
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); })
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (customKeyboardFocus !== null) if (customKeyboardFocus !== null)
@@ -545,15 +540,13 @@ Item {
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
onVisibleChanged: { onVisibleChanged: {
if (visible) { if (visible)
opened(); return;
} else {
if (Qt.inputMethod) { if (Qt.inputMethod) {
Qt.inputMethod.hide(); Qt.inputMethod.hide();
Qt.inputMethod.reset(); Qt.inputMethod.reset();
} }
} }
}
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
@@ -90,6 +90,7 @@ Item {
if (!useSingleWindow) if (!useSingleWindow)
clickCatcher.visible = true; clickCatcher.visible = true;
contentWindow.visible = true; contentWindow.visible = true;
opened();
shouldHaveFocus = false; shouldHaveFocus = false;
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
} }
@@ -251,22 +252,12 @@ Item {
} }
WlrLayershell.namespace: root.layerNamespace WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: { WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
if (root.useOverlayLayer) "allow": ["top", "overlay"],
return WlrLayershell.Overlay; "invalidLayer": WlrLayer.Top,
switch (Quickshell.env("DMS_MODAL_LAYER")) { "label": "modals",
case "bottom": "error": true
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); })
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (customKeyboardFocus !== null) if (customKeyboardFocus !== null)
@@ -296,15 +287,13 @@ Item {
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
onVisibleChanged: { onVisibleChanged: {
if (visible) { if (visible)
opened(); return;
} else {
if (Qt.inputMethod) { if (Qt.inputMethod) {
Qt.inputMethod.hide(); Qt.inputMethod.hide();
Qt.inputMethod.reset(); Qt.inputMethod.reset();
} }
} }
}
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
@@ -57,7 +57,11 @@ Rectangle {
return; return;
if (response.error) if (response.error)
return; return;
const result = response.result ?? {}; if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result;
const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString(); const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
const data = (result.data ?? "").toString(); const data = (result.data ?? "").toString();
if (data.length === 0 || !resolvedSourceUrl(data, mimeType)) if (data.length === 0 || !resolvedSourceUrl(data, mimeType))
@@ -1721,11 +1721,15 @@ Item {
return ""; return "";
var idx = text.toLowerCase().indexOf(lowerQuery); var idx = text.toLowerCase().indexOf(lowerQuery);
if (idx === -1) if (idx === -1)
return text; return _escapeRichText(text);
var before = text.substring(0, idx); var before = text.substring(0, idx);
var match = text.substring(idx, idx + queryLen); var match = text.substring(idx, idx + queryLen);
var after = text.substring(idx + queryLen); var after = text.substring(idx + queryLen);
return '<span style="color:' + baseColor + '">' + before + '</span><span style="color:' + highlightColor + '; font-weight:600">' + match + '</span><span style="color:' + baseColor + '">' + after + '</span>'; return '<span style="color:' + baseColor + '">' + _escapeRichText(before) + '</span><span style="color:' + highlightColor + '; font-weight:600">' + _escapeRichText(match) + '</span><span style="color:' + baseColor + '">' + _escapeRichText(after) + '</span>';
}
function _escapeRichText(text) {
return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
} }
function getCurrentSectionViewMode() { function getCurrentSectionViewMode() {
@@ -42,20 +42,12 @@ Item {
readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
readonly property bool usesOverlayLayer: SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer readonly property bool usesOverlayLayer: SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: { readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
switch (Quickshell.env("DMS_MODAL_LAYER")) { "allow": ["top", "overlay"],
case "bottom": "invalidLayer": WlrLayer.Top,
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); "label": "modals",
return WlrLayershell.Top; "error": true
case "background": })
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property int baseWidth: { readonly property int baseWidth: {
switch (SettingsData.dankLauncherV2Size) { switch (SettingsData.dankLauncherV2Size) {
@@ -240,52 +232,65 @@ Item {
onTriggered: root._flushSync() onTriggered: root._flushSync()
} }
property string _chromeClaimId: ""
property bool _fullSyncPending: false property bool _fullSyncPending: false
function _nextChromeClaimId() {
return "dms:launcher-v2:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000);
}
function _currentScreenName() { function _currentScreenName() {
return effectiveScreen ? effectiveScreen.name : ""; return effectiveScreen ? effectiveScreen.name : "";
} }
function _publishModalChromeState(isClaim) { ConnectedModalChrome {
const screenName = _currentScreenName(); id: modalChrome
if (!screenName) modalHandle: root.modalHandle
return; claimPrefix: "dms:launcher-v2"
surfaceKind: "launcher"
screenName: root._currentScreenName()
enabled: root.frameOwnsConnectedChrome
active: root.spotlightOpen
presented: root.spotlightOpen || contentWindow.visible
dockBlocked: root._dockBlocksEmergence
dockSide: root.resolvedConnectedBarSide
onRecoveryRequested: root._queueFullSync()
}
function _publishModalChromeState() {
const presented = spotlightOpen || contentWindow.visible;
const phase = !presented ? "hidden" : (isClosing ? "closing" : (!contentWindow.visible ? "opening" : "open"));
const bodyRect = {
"x": _connectedChromeX,
"y": _connectedChromeY,
"width": _connectedChromeWidth,
"height": _connectedChromeHeight
};
const animationOffset = {
"x": contentContainer ? contentContainer.animX : 0,
"y": contentContainer ? contentContainer.animY : 0
};
const state = { const state = {
"visible": spotlightOpen || contentWindow.visible, "kind": "launcher",
"screenName": root._currentScreenName(),
"phase": phase,
"visible": presented,
"presented": presented,
"barSide": resolvedConnectedBarSide, "barSide": resolvedConnectedBarSide,
"bodyRect": bodyRect,
"animationOffset": animationOffset,
"scale": 1,
"opacity": Theme.connectedSurfaceColor.a,
"bodyX": _connectedChromeX, "bodyX": _connectedChromeX,
"bodyY": _connectedChromeY, "bodyY": _connectedChromeY,
"bodyW": _connectedChromeWidth, "bodyW": _connectedChromeWidth,
"bodyH": _connectedChromeHeight, "bodyH": _connectedChromeHeight,
"animX": contentContainer ? contentContainer.animX : 0, "animX": animationOffset.x,
"animY": contentContainer ? contentContainer.animY : 0, "animY": animationOffset.y,
"omitStartConnector": false, "omitStartConnector": false,
"omitEndConnector": false "omitEndConnector": false,
"dockRetractSide": root._dockBlocksEmergence ? resolvedConnectedBarSide : ""
}; };
if (isClaim) return modalChrome.publish(state);
ConnectedModeState.claimModalState(screenName, state, _chromeClaimId);
else
ConnectedModeState.updateModalState(screenName, state, _chromeClaimId);
} }
function _syncModalChromeState() { function _syncModalChromeState() {
if (!frameOwnsConnectedChrome) { _publishModalChromeState();
_releaseModalChrome();
return;
}
const isClaim = !_chromeClaimId;
if (!_chromeClaimId)
_chromeClaimId = _nextChromeClaimId();
_publishModalChromeState(isClaim);
if (_dockBlocksEmergence && (spotlightOpen || contentWindow.visible))
ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide);
else
ConnectedModeState.releaseDockRetract(_chromeClaimId);
} }
property bool _animSyncQueued: false property bool _animSyncQueued: false
@@ -322,32 +327,21 @@ Item {
} }
function _syncModalAnim() { function _syncModalAnim() {
if (!frameOwnsConnectedChrome || !_chromeClaimId) if (!frameOwnsConnectedChrome)
return; return;
const screenName = _currentScreenName(); if (!contentContainer)
if (!screenName || !contentContainer)
return; return;
ConnectedModeState.setModalAnim(screenName, contentContainer.animX, contentContainer.animY, _chromeClaimId); modalChrome.updateAnim(contentContainer.animX, contentContainer.animY);
} }
function _syncModalBody() { function _syncModalBody() {
if (!frameOwnsConnectedChrome || !_chromeClaimId) if (!frameOwnsConnectedChrome)
return; return;
const screenName = _currentScreenName(); modalChrome.updateBody(_connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight);
if (!screenName)
return;
ConnectedModeState.setModalBody(screenName, _connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight, _chromeClaimId);
} }
function _releaseModalChrome() { function _releaseModalChrome() {
if (!_chromeClaimId) modalChrome.release();
return;
ConnectedModeState.releaseDockRetract(_chromeClaimId);
const claimId = _chromeClaimId;
_chromeClaimId = "";
const screenName = _currentScreenName();
if (screenName)
ConnectedModeState.clearModalState(screenName, claimId);
} }
onFrameOwnsConnectedChromeChanged: _syncModalChromeState() onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
@@ -359,8 +353,6 @@ Item {
onAlignedWidthChanged: _queueBodySync() onAlignedWidthChanged: _queueBodySync()
onAlignedHeightChanged: _queueBodySync() onAlignedHeightChanged: _queueBodySync()
Component.onDestruction: _releaseModalChrome()
Connections { Connections {
target: contentWindow target: contentWindow
function onVisibleChanged() { function onVisibleChanged() {
@@ -587,13 +579,17 @@ Item {
} }
} }
if (!needsReset) if (!needsReset) {
if (root.spotlightOpen)
root._queueFullSync();
return; return;
}
const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0];
if (!newScreen) if (!newScreen)
return; return;
root._releaseModalChrome();
root._windowEnabled = false; root._windowEnabled = false;
backgroundWindow.screen = newScreen; backgroundWindow.screen = newScreen;
contentWindow.screen = newScreen; contentWindow.screen = newScreen;
@@ -689,7 +685,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
anchors { anchors {
left: true left: true
@@ -32,20 +32,12 @@ Item {
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground
readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: { readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
switch (Quickshell.env("DMS_MODAL_LAYER")) { "allow": ["top", "overlay"],
case "bottom": "invalidLayer": WlrLayer.Top,
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); "label": "modals",
return WlrLayershell.Top; "error": true
case "background": })
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property int _openDuration: 50 readonly property int _openDuration: 50
readonly property int _closeDuration: 40 readonly property int _closeDuration: 40
@@ -345,7 +337,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
anchors { anchors {
top: true top: true
@@ -81,20 +81,12 @@ Item {
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground
readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: { readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
switch (Quickshell.env("DMS_MODAL_LAYER")) { "allow": ["top", "overlay"],
case "bottom": "invalidLayer": WlrLayer.Top,
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); "label": "modals",
return WlrLayershell.Top; "error": true
case "background": })
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property real cornerRadius: Theme.cornerRadius readonly property real cornerRadius: Theme.cornerRadius
readonly property color borderColor: { readonly property color borderColor: {
if (!SettingsData.dankLauncherV2BorderEnabled) if (!SettingsData.dankLauncherV2BorderEnabled)
@@ -381,7 +373,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
anchors { anchors {
top: true top: true
@@ -446,7 +446,7 @@ Item {
WlrLayershell.namespace: "dms:launcher-context-menu" WlrLayershell.namespace: "dms:launcher-context-menu"
WlrLayershell.layer: WlrLayershell.Overlay WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
anchors { anchors {
top: true top: true
@@ -15,6 +15,7 @@ DankModal {
shouldBeVisible: false shouldBeVisible: false
allowStacking: true allowStacking: true
useOverlayLayer: true
modalWidth: 420 modalWidth: 420
modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 200 modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 200
@@ -25,7 +25,7 @@ Popup {
dangerous: true dangerous: true
}, },
{ {
text: I18n.tr("Copy Path"), text: I18n.tr("Copy path"),
icon: "content_copy", icon: "content_copy",
action: copyPath, action: copyPath,
enabled: filePath.length > 0 enabled: filePath.length > 0
@@ -25,6 +25,7 @@ DankModal {
closeOnEscapeKey: true closeOnEscapeKey: true
closeOnBackgroundClick: true closeOnBackgroundClick: true
allowStacking: true allowStacking: true
useOverlayLayer: true
keepPopoutsOpen: true keepPopoutsOpen: true
onBackgroundClicked: close() onBackgroundClicked: close()

Some files were not shown because too many files have changed in this diff Show More