1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-06 21:48:30 -04:00

Compare commits

...

78 Commits

Author SHA1 Message Date
purian23 d91481c464 fix(nix): restore pre-wrap Qt paths for launcher-spawned apps 2026-07-04 23:10:49 -04:00
bbedward dd7f4f3ac5 audio/bluez: update device aliases at runtime when renamed
fixes #1628
2026-07-04 23:00:22 -04:00
bbedward 3df801e3b2 keyboard layout: add support for sway
fixes #1681
2026-07-04 22:55:50 -04:00
purian23 74d220767d fix(greeter): update user cursor theme support
- Fixes #2054
2026-07-04 22:52:16 -04:00
bbedward a3fba8fcb6 matugen: make GTK4 live-refresh opt-in, off by default. Bump the settle
timeout
fixes #2552
2026-07-04 22:38:49 -04:00
purian23 33384e4d5d fix(niri): update custom keyboard shortcut parsing properties
Fixes #2187
2026-07-04 22:25:53 -04:00
purian23 78f2ea4642 fix(Pywalfox): update distro detection handling
- Fixes #2220
2026-07-04 22:17:55 -04:00
purian23 7dffe85e94 fix(iwd): update rfkill to allow backend management
- Fixes #2221
2026-07-04 21:57:27 -04:00
purian23 c5c3105469 fix(Bluetooth): update codec selection and error handling
Fixes #2240
2026-07-04 21:48:34 -04:00
bbedward 8f100960cf theme: fix gtk3 checkboxes in light mode
fixes #1555
2026-07-04 21:28:08 -04:00
bbedward 8a5e7c78fb dankbar: keep blur enabled at opacity-0 2026-07-04 20:57:07 -04:00
bbedward 8a85ab06a1 portal: prevent feedback loop with the gtk4 hack to make it react to
color scheme changes
2026-07-04 20:43:32 -04:00
bbedward 30ceda97a0 brightness: avoid targeting inactive panels
fixes #1127
2026-07-04 20:43:32 -04:00
purian23 fefc0afa6f fix(SystemTrayBar): improve menu position boundaries
- Fixes #2252
2026-07-04 20:24:59 -04:00
bbedward b01042d530 brightness/ddc: avoid re-probing already identified devices
related #2049
2026-07-04 20:12:27 -04:00
bbedward 7355cc54b2 display config: persist applied layout to auto profile and skip no-op re-apply on startup
related #2526
2026-07-04 20:03:11 -04:00
purian23 187b7ba8ff feat(Settings): add option to show icon for focused windows
- Closes #2122
2026-07-04 18:42:40 -04:00
purian23 c0e7c1b2b4 feat(Battery): add horizontal battery pill style option in widget settings 2026-07-04 18:41:52 -04:00
bbedward ea0c235d6e notepad: make dentry NoDisplay 2026-07-04 17:00:43 -04:00
bbedward 57a04cf409 i18n: add mechanism for blocking new terms 2026-07-04 16:51:28 -04:00
bbedward fd99558ce5 clipboard: implement virtual-keyboard-unstable-v1 to replace wtype for
pasting entries
2026-07-04 16:24:14 -04:00
purian23 81a4d3b4e0 feat(Notepad): add notepad desktop entry & IPC commands file handling 2026-07-04 16:10:56 -04:00
Scott McKendry bfda5f2d66 fix(lockscreen): clock off-center with single hour digit (#2745) 2026-07-04 15:18:34 -04:00
bbedward 65c4167ab2 display config: fix mixed ID/model identifiers
fixes #2736
2026-07-04 13:24:53 -04:00
bbedward e7bd8e4002 greeter: fix weather location sync
fixes #2303
2026-07-04 13:06:49 -04:00
bbedward 25a1b125b7 keyboard layout: add consistent helper to map layout codes to display
fixes #1849
2026-07-04 11:57:17 -04:00
bbedward d766c0773c theme: fix setting color mode when no wallpaper is set in auto mode
fixes #2239
2026-07-04 11:40:27 -04:00
Huỳnh Thiện Lộc 5d12962fd6 fix(clipboard): fix action buttons hover cursor shape (#2744) 2026-07-04 11:31:39 -04:00
bbedward 9911698237 lock: synchronize password inputs between displays and fix video
screensave/password focus
fixes #1933
2026-07-04 11:29:41 -04:00
Huỳnh Thiện Lộc 7e095415a7 refactor(battery): align status layout and add individual notification types (#2743)
* refactor(battery): restore status list layout and add individual notification types

* settings/battery: fix RTL display of top tablular-like view

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-07-04 11:20:44 -04:00
bbedward 68d5d7c5ed wallpaper: persist cycling state to disk
fixes #1010
2026-07-04 11:17:13 -04:00
Scott McKendry 7b1c542585 feat: lockscreen screen filter (#2740)
this makes use of the existing getFilteredScreens function to allow
users to specify which displays the lockscreen should appear on. prior
to this change, it was "all" or just one.
2026-07-04 11:04:59 -04:00
Kangheng Liu 52ed719489 fix: add missing vertical anchor for cava/music icon (#2742) 2026-07-04 10:58:55 -04:00
bbedward 6e508d416d core/wayland: fix fatal error handling when the socket becomes
unavailable
2026-07-04 10:52:52 -04:00
bbedward 518cc93276 weather: fix location search with han characters
fixes #2274
2026-07-04 10:48:08 -04:00
bbedward 8cb4e21716 animations: fix regression eliding button group text caused by recently
RTL fixes
fixes #2741
2026-07-04 10:44:02 -04:00
bbedward 19b44b1782 settings: make display config identify only show when interacting 2026-07-04 10:30:24 -04:00
purian23 0509694d78 fix(Notifications): update flexible image caching in DankCircularImage
- Fixes #2480
2026-07-04 01:26:56 -04:00
purian23 da44e07cf7 fix(International keyboards): update key mapping w/symbolic & shifted fallback support
- Fixes #2497
2026-07-04 01:13:49 -04:00
purian23 b34941e3b8 fix(keybinds): respect niri mod-key when capturing binds
Hardens #2224
2026-07-04 00:50:58 -04:00
purian23 c554d973ef fix(Hover): refactor & update hover tracking w/context menus
Fixes #2737
2026-07-04 00:08:58 -04:00
bbedward 2861cc89c6 idle: fix screensaver inhibit interfering with manually triggered
inhibitor
2026-07-03 23:27:19 -04:00
bbedward 1c5965f1e4 matugen: fix vscode text legibility on auto-complete menu
fixes #2198
2026-07-03 23:18:32 -04:00
bbedward 66d552b3d5 niri: re-work layer-rule to exclude bar for xray instead of using 2
rules
2026-07-03 23:13:25 -04:00
bbedward ebad8e2dda mango: add specific sorting for widgets using data from mango IPC
fixes #649
2026-07-03 23:11:34 -04:00
purian23 8dadf73d52 refactor: (modals/popouts): sync functions & improve blur calculations across modals 2026-07-03 22:20:10 -04:00
bbedward 9f77b3127c control center: fix brightness icon not showing up if no pinned devices
fixes #2488
2026-07-03 21:59:01 -04:00
bbedward 46f8457777 niri: make xdg pictures dir for screenshot, in case it doesnt exist
fixes #1577
2026-07-03 21:56:10 -04:00
Huỳnh Thiện Lộc 8465ed4311 feat(plugins): add update all CLI flag and settings updates dialog (#2682)
* feat(plugins): add update all CLI flag and settings update dialog

* feat(plugins): add comparison diff URL support and update button styling

* feat(plugins): skip system plugins in bulk CLI update

* fix(plugins): remove check shorthand to resolve conflict with config flag

* feat(plugins): inline update dialog, show version tags, restructure buttons
2026-07-03 21:51:39 -04:00
bbedward 6e3e178721 running apps: fix alignment of fallback icons in non-compact mode
fixes #1963
fixes #2024
2026-07-03 17:04:45 -04:00
bbedward 27230227ef tmux: fix new session hint
fixes #2556
2026-07-03 16:56:32 -04:00
purian23 7178563c2c fix(sway): correctly handle Sway named workspaces
- Fixes #2531
2026-07-03 16:33:44 -04:00
bbedward dfe309a543 dock: fix launcher button color override
fixes #2530
2026-07-03 16:29:06 -04:00
bbedward 04962ffed1 desktop widgets: fix rounding of system monitor graphs
fixes #2356
2026-07-03 15:50:06 -04:00
bbedward 5008406be8 display config: fix monitor names overflowing, add identity overlay to
ease configuration
fixes #1398
2026-07-03 15:45:58 -04:00
bbedward 930a36bcfc animations: add DankColorAnimation helper to properly animate between
colors with different alpha values
fixes #2720
2026-07-03 15:36:45 -04:00
bbedward 3253adb6dd workspace: fix display of named workspace + icon set on vertical bars
fixes #2067
2026-07-03 15:21:13 -04:00
purian23 b8fd52e00f fix(Notifications): update hasNotificationImage logic to support file paths
- Fixes #2533
2026-07-03 13:38:21 -04:00
bbedward 8e7126472e workspaces: fix hardcoded font weight to use configured value
fixes #2076
2026-07-03 12:10:45 -04:00
bbedward 61108b7668 i18n: numerous RTL layout fixes across DankDash and Settings 2026-07-03 11:57:09 -04:00
bbedward 09ca37c9fc workspace: fix occupied color in niri
fixes #2538
2026-07-03 11:21:50 -04:00
purian23 a2513e939a fix(NiriService): enhance column movement functions to focus on specified monitor
- Fixes #2498
2026-07-03 00:14:14 -04:00
purian23 a73380b310 fix(PowerMenu): defer keyboard hold actions to key release
- Fixes #2489
2026-07-03 00:02:35 -04:00
purian23 69d7c1fb13 fix(WorkspaceSwitcher): color handling w/animation transition
- Fixes #2720
2026-07-02 23:50:47 -04:00
bbedward d1eb655e46 theme: fix accent colors in light mode
fixes #2711
2026-07-02 23:38:14 -04:00
purian23 a3ecef7627 fix(PluginBrowser): simplify model binding for filtered plugins
- Fixes #2721
2026-07-02 23:01:23 -04:00
bbedward 186621ab81 lock: set LockedHint when locking with loginctl lock integration
- was previously read-only
fixes #2732
2026-07-02 22:57:09 -04:00
bbedward 31efe9b385 hyprland: fix named workspaces hiding from widget
fixes #2047
2026-07-02 22:49:09 -04:00
purian23 6cdb891551 feat(matugen): add color preview pallette & outline border option theme settings
- Closes #1738
2026-07-02 22:45:55 -04:00
bbedward 3bb3da228c dock: fix context menu overflow
fixes #2287
2026-07-02 22:12:02 -04:00
purian23 0c99e4b8d1 refactor(compositor): reduce unnecessary UI updates
- Defer updates when window state hasn't actually changed
- Cache frame-blocked state per screen instead of recalculating constantly
- keep popouts cache so reopening them is instant & clean up otherwise
2026-07-02 21:42:20 -04:00
bbedward 8b79d1dad3 notifications: fix blur on popout
fixes #2288
2026-07-02 21:32:10 -04:00
bbedward 85fa3326c1 blur: fix blur for goth corners in dankbar
fixes #2666
2026-07-02 20:27:42 -04:00
purian23 2fade1a617 fix(notepad): use surfaceVariantText color for line numbers 2026-07-02 17:14:32 -04:00
purian23 597ba597e5 feat(settings): implement greeter & lockscreen customization options
- New option to customize your font & wallpaper on the LockScreen
- New floating sync button if missed to explicitly sync on greeter changes
2026-07-02 17:13:18 -04:00
purian23 957020c9ea fix(nix): support declarative greeter auto-login (#2734) 2026-07-02 17:33:10 -03:00
bbedward ae520620ac idle: fix idle monitors firing when bar isnt visible + keep awake is
active
fixes #2698
2026-07-02 15:51:22 -04:00
bbedward 4c43bed2f8 lock: fix custom lock commands with lock before suspend and hibernate
fixes #2326
2026-07-02 15:41:05 -04:00
268 changed files with 18525 additions and 6349 deletions
+8
View File
@@ -28,6 +28,14 @@ repos:
language: system language: system
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$ files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: i18n-term-freeze
name: i18n term freeze (no new I18n.tr/qsTr terms)
entry: python3 quickshell/translations/check_term_freeze.py
language: system
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: no-console-in-qml - id: no-console-in-qml
+2
View File
@@ -77,6 +77,7 @@ install-desktop:
@echo "Installing desktop entries..." @echo "Installing desktop entries..."
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop @install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop @install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed" @echo "Desktop entries installed"
@@ -120,6 +121,7 @@ uninstall-desktop:
@echo "Removing desktop entries..." @echo "Removing desktop entries..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop @rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop @rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries removed" @echo "Desktop entries removed"
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=DMS Notepad
GenericName=Text Editor
Comment=Quick notes and text files in the dank desktop shell
Exec=dms ipc call notepad openFile %f
Icon=danklogo
Terminal=false
NoDisplay=true
Categories=Utility;TextEditor;
MimeType=text/plain;text/markdown;
StartupNotify=false
+18 -1
View File
@@ -63,6 +63,15 @@ var clipPasteCmd = &cobra.Command{
Run: runClipPaste, Run: runClipPaste,
} }
var clipSendPasteCmd = &cobra.Command{
Use: "send-paste",
Short: "Send a paste keystroke to the focused window",
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
Run: runClipSendPaste,
}
var clipSendPasteShift bool
var clipWatchCmd = &cobra.Command{ var clipWatchCmd = &cobra.Command{
Use: "watch [command]", Use: "watch [command]",
Short: "Watch clipboard for changes", Short: "Watch clipboard for changes",
@@ -216,8 +225,10 @@ func init() {
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration") clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd) clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
} }
func runClipCopy(cmd *cobra.Command, args []string) { func runClipCopy(cmd *cobra.Command, args []string) {
@@ -314,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
os.Stdout.Write(data) os.Stdout.Write(data)
} }
func runClipSendPaste(cmd *cobra.Command, args []string) {
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
log.Fatalf("send-paste: %v", err)
}
}
func runClipWatch(cmd *cobra.Command, args []string) { func runClipWatch(cmd *cobra.Command, args []string) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
+197 -5
View File
@@ -106,6 +106,8 @@ func init() {
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
printIPCHelp() printIPCHelp()
}) })
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
} }
var debugSrvCmd = &cobra.Command{ var debugSrvCmd = &cobra.Command{
@@ -184,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{
} }
var pluginsUpdateCmd = &cobra.Command{ var pluginsUpdateCmd = &cobra.Command{
Use: "update <plugin-id>", Use: "update [plugin-id]",
Short: "Update a plugin by ID", Short: "Update a plugin by ID, or all plugins",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.", Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
Args: cobra.ExactArgs(1), Args: func(cmd *cobra.Command, args []string) error {
updateAll, _ := cmd.Flags().GetBool("all")
if updateAll {
if len(args) > 0 {
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
}
return nil
}
if len(args) != 1 {
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
}
return nil
},
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 nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
@@ -195,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
checkOnly, _ := cmd.Flags().GetBool("check")
updateAll, _ := cmd.Flags().GetBool("all")
if checkOnly {
if updateAll {
if err := checkAllPluginsCLI(); err != nil {
log.Fatalf("Error checking updates: %v", err)
}
return
}
if err := checkPluginCLI(args[0]); err != nil {
log.Fatalf("Error checking update: %v", err)
}
return
}
if updateAll {
if err := updateAllPluginsCLI(); err != nil {
log.Fatalf("Error updating plugins: %v", err)
}
return
}
if err := updatePluginCLI(args[0]); err != nil { if err := updatePluginCLI(args[0]); err != nil {
log.Fatalf("Error updating plugin: %v", err) log.Fatalf("Error updating plugin: %v", err)
} }
@@ -370,7 +404,11 @@ func listInstalledPlugins() error {
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames)) fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
for _, id := range installedNames { for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok { if plugin, ok := pluginMap[id]; ok {
fmt.Printf(" %s\n", plugin.Name) hasUpdateStr := ""
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
hasUpdateStr = " (update available)"
}
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
fmt.Printf(" ID: %s\n", plugin.ID) fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category) fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author) fmt.Printf(" Author: %s\n", plugin.Author)
@@ -550,6 +588,160 @@ func updatePluginCLI(idOrName string) error {
return nil return nil
} }
func updateAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var errs []error
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
if plugin != nil {
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Update(*plugin); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
}
} else {
fmt.Printf("Updating plugin: %s\n", pluginID)
if err := manager.UpdateByIDOrName(pluginID); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", pluginID)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
}
}
}
if len(errs) > 0 {
for _, err := range errs {
fmt.Fprintf(os.Stderr, "%v\n", err)
}
return fmt.Errorf("failed to update some plugins")
}
return nil
}
func checkPluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(idOrName, pluginList)
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
} else {
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
}
return nil
}
dummyPlugin := plugins.Plugin{ID: idOrName}
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s\n", idOrName)
} else {
fmt.Printf("Plugin is up to date: %s\n", idOrName)
}
return nil
}
func checkAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var count int
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
var hasUpdates bool
var name string
if plugin != nil {
name = plugin.Name
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
} else {
name = pluginID
dummyPlugin := plugins.Plugin{ID: pluginID}
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
count++
}
}
if count > 0 {
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
} else {
fmt.Println("All plugins are up to date.")
}
return nil
}
func getCommonCommands() []*cobra.Command { func getCommonCommands() []*cobra.Command {
return []*cobra.Command{ return []*cobra.Command{
versionCmd, versionCmd,
+23
View File
@@ -38,10 +38,17 @@ var matugenCheckCmd = &cobra.Command{
Run: runMatugenCheck, Run: runMatugenCheck,
} }
var matugenPreviewCmd = &cobra.Command{
Use: "preview",
Short: "Preview Matugen scheme colors without applying them",
Run: runMatugenPreview,
}
func init() { func init() {
matugenCmd.AddCommand(matugenGenerateCmd) matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd) matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd) matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} { for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files") cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -62,6 +69,8 @@ func init() {
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting") matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
} }
func buildMatugenOptions(cmd *cobra.Command) matugen.Options { func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -200,3 +209,17 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
} }
fmt.Println(string(data)) fmt.Println(string(data))
} }
func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
contrast, _ := cmd.Flags().GetFloat64("contrast")
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err)
}
data, err := json.Marshal(previews)
if err != nil {
log.Fatalf("Failed to marshal Matugen previews: %v", err)
}
fmt.Println(string(data))
}
+147 -319
View File
@@ -9,7 +9,6 @@ import (
"syscall" "syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
const envServe = "_DMS_CLIPBOARD_SERVE" const envServe = "_DMS_CLIPBOARD_SERVE"
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE" const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
const envCacheFile = "_DMS_CLIPBOARD_CACHE" const envCacheFile = "_DMS_CLIPBOARD_CACHE"
type Offer struct {
MimeType string
Data []byte
}
// textMimeAliases are offered alongside plain-text content so legacy X11
// clients bridged through XWayland find a target they can convert.
var textMimeAliases = []string{
"text/plain",
"text/plain;charset=utf-8",
"UTF8_STRING",
"STRING",
"TEXT",
}
// ExpandOffers turns raw clipboard data into the full offer list to serve,
// adding the standard alias set for text content.
func ExpandOffers(data []byte, mimeType string) []Offer {
offers := []Offer{{MimeType: mimeType, Data: data}}
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
return offers
}
for _, alias := range textMimeAliases {
if alias == mimeType {
continue
}
offers = append(offers, Offer{MimeType: alias, Data: data})
}
return offers
}
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard // MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
// child. Reads source data into memory, deletes any cache file, then serves. // child. Reads source data into memory, deletes any cache file, then serves.
func MaybeServeAndExit() { func MaybeServeAndExit() {
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
os.Exit(1) os.Exit(1)
} }
if err := serveClipboard(data, mimeType, pasteOnce); err != nil { if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err) fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -55,22 +85,33 @@ func Copy(data []byte, mimeType string) error {
return copyForkCached(data, mimeType, false) return copyForkCached(data, mimeType, false)
} }
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error { func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground { if foreground {
return serveClipboard(data, mimeType, pasteOnce) return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
} }
return copyForkCached(data, mimeType, pasteOnce) return copyForkCached(data, mimeType, pasteOnce)
} }
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error { func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if foreground { if !foreground {
buf, err := io.ReadAll(data) return copyFork(data, mimeType, pasteOnce)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
} }
return copyFork(data, mimeType, pasteOnce) buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if foreground {
return serveOffers(offers, pasteOnce)
}
return copyMultiFork(offers, pasteOnce)
} }
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd { func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
@@ -132,39 +173,70 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error { func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce) cmd := newForkCmd(mimeType, pasteOnce)
switch src := data.(type) { if src, ok := data.(*os.File); ok {
case *os.File:
cmd.Stdin = src cmd.Stdin = src
return waitReady(cmd) return waitReady(cmd)
default:
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
} }
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
} }
func signalReady() { func signalReady() {
@@ -194,57 +266,25 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*") return os.CreateTemp("", "dms-clipboard-*")
} }
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error { // serveOffers owns the Wayland selection until cancelled (or first paste when
display, err := wlclient.Connect("") // pasteOnce is set), answering every offered mime type with its data.
func serveOffers(offers []Offer, pasteOnce bool) error {
if len(offers) == 0 {
return fmt.Errorf("no offers to serve")
}
s, err := connectSession()
if err != nil { if err != nil {
return fmt.Errorf("wayland connect: %w", err) return err
} }
defer display.Destroy() defer s.Close()
ctx := display.Context() dataControlMgr, err := s.requireDataControl()
registry, err := display.GetRegistry()
if err != nil { if err != nil {
return fmt.Errorf("get registry: %w", err) return err
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
} }
device, err := dataControlMgr.GetDataDevice(seat) device, err := dataControlMgr.GetDataDevice(s.seat)
if err != nil { if err != nil {
return fmt.Errorf("get data device: %w", err) return fmt.Errorf("get data device: %w", err)
} }
@@ -255,25 +295,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return fmt.Errorf("create data source: %w", err) return fmt.Errorf("create data source: %w", err)
} }
if err := source.Offer(mimeType); err != nil { offerData := make(map[string][]byte, len(offers))
return fmt.Errorf("offer mime type: %w", err) for _, offer := range offers {
} if err := source.Offer(offer.MimeType); err != nil {
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" { return fmt.Errorf("offer %s: %w", offer.MimeType, err)
if err := source.Offer("text/plain"); err != nil {
return fmt.Errorf("offer text/plain: %w", err)
}
if err := source.Offer("text/plain;charset=utf-8"); err != nil {
return fmt.Errorf("offer text/plain;charset=utf-8: %w", err)
}
if err := source.Offer("UTF8_STRING"); err != nil {
return fmt.Errorf("offer UTF8_STRING: %w", err)
}
if err := source.Offer("STRING"); err != nil {
return fmt.Errorf("offer STRING: %w", err)
}
if err := source.Offer("TEXT"); err != nil {
return fmt.Errorf("offer TEXT: %w", err)
} }
offerData[offer.MimeType] = offer.Data
} }
cancelled := make(chan struct{}) cancelled := make(chan struct{})
@@ -283,7 +310,11 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
_ = syscall.SetNonblock(e.Fd, false) _ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe") file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close() defer file.Close()
_, _ = file.Write(data)
if data, ok := offerData[e.MimeType]; ok {
_, _ = file.Write(data)
}
select { select {
case pasted <- struct{}{}: case pasted <- struct{}{}:
default: default:
@@ -298,7 +329,7 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return fmt.Errorf("set selection: %w", err) return fmt.Errorf("set selection: %w", err)
} }
display.Roundtrip() s.display.Roundtrip()
signalReady() signalReady()
for { for {
@@ -310,70 +341,26 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return nil return nil
} }
default: default:
if err := ctx.Dispatch(); err != nil { if err := s.ctx.Dispatch(); err != nil {
return nil return nil
} }
} }
} }
} }
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func Paste() ([]byte, string, error) { func Paste() ([]byte, string, error) {
display, err := wlclient.Connect("") s, err := connectSession()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("wayland connect: %w", err) return nil, "", err
} }
defer display.Destroy() defer s.Close()
ctx := display.Context() dataControlMgr, err := s.requireDataControl()
registry, err := display.GetRegistry()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("get registry: %w", err) return nil, "", err
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return nil, "", fmt.Errorf("registry bind: %w", bindErr)
} }
if dataControlMgr == nil { device, err := dataControlMgr.GetDataDevice(s.seat)
return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return nil, "", fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("get data device: %w", err) return nil, "", fmt.Errorf("get data device: %w", err)
} }
@@ -399,15 +386,14 @@ func Paste() ([]byte, string, error) {
gotSelection = true gotSelection = true
}) })
display.Roundtrip() s.display.Roundtrip()
display.Roundtrip() s.display.Roundtrip()
if !gotSelection || selectionOffer == nil { if !gotSelection || selectionOffer == nil {
return nil, "", fmt.Errorf("no clipboard data") return nil, "", fmt.Errorf("no clipboard data")
} }
mimeTypes := offerMimeTypes[selectionOffer] selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
selectedMime := selectPreferredMimeType(mimeTypes)
if selectedMime == "" { if selectedMime == "" {
return nil, "", fmt.Errorf("no supported mime type") return nil, "", fmt.Errorf("no supported mime type")
} }
@@ -424,7 +410,7 @@ func Paste() ([]byte, string, error) {
} }
w.Close() w.Close()
display.Roundtrip() s.display.Roundtrip()
data, err := io.ReadAll(r) data, err := io.ReadAll(r)
if err != nil { if err != nil {
@@ -470,161 +456,3 @@ func selectPreferredMimeType(mimes []string) string {
func IsImageMimeType(mime string) bool { func IsImageMimeType(mime string) bool {
return len(mime) > 6 && mime[:6] == "image/" return len(mime) > 6 && mime[:6] == "image/"
} }
type Offer struct {
MimeType string
Data []byte
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if !foreground {
return copyMultiFork(offers, pasteOnce)
}
return copyMultiServe(offers, pasteOnce)
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
}
func copyMultiServe(offers []Offer, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
}
defer display.Destroy()
ctx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
defer device.Destroy()
source, err := dataControlMgr.CreateDataSource()
if err != nil {
return fmt.Errorf("create data source: %w", err)
}
offerMap := make(map[string][]byte)
for _, offer := range offers {
if err := source.Offer(offer.MimeType); err != nil {
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
}
offerMap[offer.MimeType] = offer.Data
}
cancelled := make(chan struct{})
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
_, _ = file.Write(data)
}
select {
case pasted <- struct{}{}:
default:
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
close(cancelled)
})
if err := device.SetSelection(source); err != nil {
return fmt.Errorf("set selection: %w", err)
}
display.Roundtrip()
for {
select {
case <-cancelled:
return nil
case <-pasted:
if pasteOnce {
return nil
}
default:
if err := ctx.Dispatch(); err != nil {
return nil
}
}
}
}
+206
View File
@@ -0,0 +1,206 @@
package clipboard
import (
"fmt"
"regexp"
"strconv"
"strings"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
const (
xkbKeymapFormatV1 = 1
keyStateReleased = 0
keyStatePressed = 1
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
shiftModMask = 1 << 0
ctrlModMask = 1 << 2
// evdev fallbacks for a standard pc105 map
fallbackCtrlKey = 29 // KEY_LEFTCTRL
fallbackShiftKey = 42 // KEY_LEFTSHIFT
fallbackVKey = 47 // KEY_V
)
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
// using the seat's own keymap, so keycodes stay valid for XWayland clients
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
// selects ctrl+shift+v for terminal targets.
func SendPasteKeystroke(withShift bool) error {
s, err := connectSession()
if err != nil {
return err
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
return fmt.Errorf("no seat available")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
return fmt.Errorf("get keyboard: %w", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
return fmt.Errorf("no xkb keymap from seat")
}
defer unix.Close(keymap.Fd)
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
return fmt.Errorf("read keymap: %w", err)
}
keys := resolveKeycodes(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil {
return fmt.Errorf("create virtual keyboard: %w", err)
}
defer vk.Destroy()
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
return fmt.Errorf("set keymap: %w", err)
}
mods := uint32(ctrlModMask)
held := []uint32{keys.ctrl}
if withShift {
mods |= shiftModMask
held = append(held, keys.shift)
}
t := uint32(0)
press := func(key, state uint32) error {
t++
return vk.Key(t, key, state)
}
for _, key := range held {
if err := press(key, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
}
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
return fmt.Errorf("set modifiers: %w", err)
}
if err := press(keys.v, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
if err := press(keys.v, keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
for i := len(held) - 1; i >= 0; i-- {
if err := press(held[i], keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
}
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
return fmt.Errorf("clear modifiers: %w", err)
}
s.display.Roundtrip()
return nil
}
func readKeymap(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
type pasteKeycodes struct {
ctrl uint32
shift uint32
v uint32
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// xkbcommon may serialize keysyms as hex escapes instead of names
// (e.g. "0x76" for v, "0xffe3" for Control_L).
var keysymNames = map[uint32]string{
0x76: "v",
0xffe3: "Control_L",
0xffe1: "Shift_L",
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
// the seat keymap's first group, falling back to pc105 positions.
func resolveKeycodes(keymap string) pasteKeycodes {
codes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
codes[m[1]] = uint32(code)
}
}
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
want := map[string]*uint32{
"Control_L": &keys.ctrl,
"Shift_L": &keys.shift,
"v": &keys.v,
}
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := codes[m[1]]
if !ok || xkbCode < 8 {
continue
}
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
target, wanted := want[level1]
if !wanted {
continue
}
*target = xkbCode - 8
delete(want, level1)
if len(want) == 0 {
break
}
}
return keys
}
@@ -0,0 +1,65 @@
package clipboard
import (
"os"
"testing"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
func TestLiveSeatKeymapResolution(t *testing.T) {
if os.Getenv("DMS_LIVE_TEST") == "" {
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
}
s, err := connectSession()
if err != nil {
t.Fatalf("connect: %v", err)
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
t.Fatal("no seat")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
t.Fatalf("get keyboard: %v", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil {
t.Fatal("no keymap event")
}
defer unix.Close(keymap.Fd)
text, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
t.Fatalf("read keymap: %v", err)
}
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
t.Fatalf("dump keymap: %v", err)
}
}
keys := resolveKeycodes(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
}
+105
View File
@@ -0,0 +1,105 @@
package clipboard
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
func TestResolveKeycodes(t *testing.T) {
tests := []struct {
name string
keymap string
want pasteKeycodes
}{
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveKeycodes(tt.keymap)
if got != tt.want {
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestExpandOffers(t *testing.T) {
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
if len(text) != 5 {
t.Fatalf("expected 5 text offers, got %d", len(text))
}
seen := map[string]bool{}
for _, o := range text {
if string(o.Data) != "hi" {
t.Errorf("offer %s has wrong data", o.MimeType)
}
if seen[o.MimeType] {
t.Errorf("duplicate offer %s", o.MimeType)
}
seen[o.MimeType] = true
}
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
t.Errorf("missing X11 alias offers: %v", seen)
}
img := ExpandOffers([]byte{1}, "image/png")
if len(img) != 1 || img[0].MimeType != "image/png" {
t.Errorf("non-text mime should not expand, got %+v", img)
}
}
+93
View File
@@ -0,0 +1,93 @@
package clipboard
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type session struct {
display *wlclient.Display
ctx *wlclient.Context
registry *wlclient.Registry
seat *wlclient.Seat
dataControlMgr *ext_data_control.ExtDataControlManagerV1
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
}
// connectSession opens a short-lived Wayland connection and binds the seat
// plus whichever clipboard-related globals the compositor advertises.
func connectSession() (*session, error) {
display, err := wlclient.Connect("")
if err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
s := &session{display: display, ctx: display.Context()}
registry, err := display.GetRegistry()
if err != nil {
display.Destroy()
return nil, fmt.Errorf("get registry: %w", err)
}
s.registry = registry
var bindErr error
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
if err := registry.Bind(name, iface, version, proxy); err != nil {
bindErr = fmt.Errorf("bind %s: %w", iface, err)
}
}
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case ext_data_control.ExtDataControlManagerV1InterfaceName:
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.dataControlMgr = mgr
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.virtualKeyboardMgr = mgr
case "wl_seat":
if s.seat != nil {
return
}
seat := wlclient.NewSeat(s.ctx)
bind(e.Name, e.Interface, e.Version, seat)
s.seat = seat
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
s.Close()
return nil, bindErr
}
return s, nil
}
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
switch {
case s.dataControlMgr == nil:
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
case s.seat == nil:
return nil, fmt.Errorf("no seat available")
default:
return s.dataControlMgr, nil
}
}
func (s *session) Close() {
if s.dataControlMgr != nil {
s.dataControlMgr.Destroy()
}
if s.registry != nil {
s.registry.Destroy()
}
s.display.Destroy()
}
+36 -9
View File
@@ -19,6 +19,20 @@ type NiriProvider struct {
parsed bool parsed bool
} }
type niriActionPart struct {
value string
quoted bool
}
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"}
var niriActionProperties = map[string]struct{}{
"focus": {},
"show-pointer": {},
"write-to-disk": {},
"skip-confirmation": {},
"delay-ms": {},
}
func NewNiriProvider(configDir string) *NiriProvider { func NewNiriProvider(configDir string) *NiriProvider {
if configDir == "" { if configDir == "" {
configDir = defaultNiriConfigDir() configDir = defaultNiriConfigDir()
@@ -55,6 +69,7 @@ func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
sheet := &keybinds.CheatSheet{ sheet := &keybinds.CheatSheet{
Title: "Niri Keybinds", Title: "Niri Keybinds",
Provider: n.Name(), Provider: n.Name(),
ModKey: result.ModKey,
Binds: categorizedBinds, Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded, DMSBindsIncluded: result.DMSBindsIncluded,
} }
@@ -352,7 +367,7 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
} }
if actionNode.Properties != nil { if actionNode.Properties != nil {
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} { for _, propName := range niriActionPropertyOrder {
if val, ok := actionNode.Properties.Get(propName); ok { if val, ok := actionNode.Properties.Get(propName); ok {
parts = append(parts, propName+"="+val.String()) parts = append(parts, propName+"="+val.String())
} }
@@ -440,10 +455,10 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
return node return node
} }
node.SetName(parts[0]) node.SetName(parts[0].value)
for _, arg := range parts[1:] { for _, arg := range parts[1:] {
if strings.Contains(arg, "=") { if n.isNiriActionPropertyToken(arg) {
kv := strings.SplitN(arg, "=", 2) kv := strings.SplitN(arg.value, "=", 2)
switch kv[1] { switch kv[1] {
case "true": case "true":
node.AddProperty(kv[0], true, "") node.AddProperty(kv[0], true, "")
@@ -454,13 +469,25 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
} }
continue continue
} }
node.AddArgument(arg, "") node.AddArgument(arg.value, "")
} }
return node return node
} }
func (n *NiriProvider) parseActionParts(action string) []string { func (n *NiriProvider) isNiriActionPropertyToken(part niriActionPart) bool {
var parts []string if part.quoted || !strings.Contains(part.value, "=") {
return false
}
key, _, ok := strings.Cut(part.value, "=")
if !ok {
return false
}
_, ok = niriActionProperties[key]
return ok
}
func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
var parts []niriActionPart
var current strings.Builder var current strings.Builder
var inQuote, escaped, wasQuoted bool var inQuote, escaped, wasQuoted bool
@@ -476,7 +503,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
inQuote = !inQuote inQuote = !inQuote
case r == ' ' && !inQuote: case r == ' ' && !inQuote:
if current.Len() > 0 || wasQuoted { if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String()) parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
current.Reset() current.Reset()
wasQuoted = false wasQuoted = false
} }
@@ -485,7 +512,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
} }
} }
if current.Len() > 0 || wasQuoted { if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String()) parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
} }
return parts return parts
} }
@@ -33,6 +33,7 @@ type NiriSection struct {
type NiriParser struct { type NiriParser struct {
configDir string configDir string
modKey string
processedFiles map[string]bool processedFiles map[string]bool
bindMap map[string]*NiriKeyBinding bindMap map[string]*NiriKeyBinding
bindOrder []string bindOrder []string
@@ -237,6 +238,7 @@ func isBraceAdjacentSpace(b byte) bool {
func NewNiriParser(configDir string) *NiriParser { func NewNiriParser(configDir string) *NiriParser {
return &NiriParser{ return &NiriParser{
configDir: configDir, configDir: configDir,
modKey: "Super",
processedFiles: make(map[string]bool), processedFiles: make(map[string]bool),
bindMap: make(map[string]*NiriKeyBinding), bindMap: make(map[string]*NiriKeyBinding),
bindOrder: []string{}, bindOrder: []string{},
@@ -377,6 +379,8 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
switch name { switch name {
case "include": case "include":
p.handleInclude(node, section, baseDir) p.handleInclude(node, section, baseDir)
case "input":
p.handleInput(node)
case "binds": case "binds":
p.extractBinds(node, section, "") p.extractBinds(node, section, "")
case "recent-windows": case "recent-windows":
@@ -385,6 +389,19 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
} }
} }
func (p *NiriParser) handleInput(node *document.Node) {
for _, child := range node.Children {
if child.Name.String() != "mod-key" || len(child.Arguments) == 0 {
continue
}
modKey := strings.Trim(strings.TrimSpace(child.Arguments[0].String()), "\"")
if modKey != "" {
p.modKey = modKey
}
}
}
func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) { func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) {
if len(node.Arguments) == 0 { if len(node.Arguments) == 0 {
return return
@@ -534,6 +551,7 @@ func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) {
type NiriParseResult struct { type NiriParseResult struct {
Section *NiriSection Section *NiriSection
ModKey string
DMSBindsIncluded bool DMSBindsIncluded bool
DMSStatus *DMSBindsStatusInfo DMSStatus *DMSBindsStatusInfo
ConflictingConfigs map[string]*NiriKeyBinding ConflictingConfigs map[string]*NiriKeyBinding
@@ -586,6 +604,7 @@ func ParseNiriKeys(configDir string) (*NiriParseResult, error) {
} }
return &NiriParseResult{ return &NiriParseResult{
Section: section, Section: section,
ModKey: parser.modKey,
DMSBindsIncluded: parser.HasDMSBindsIncluded(), DMSBindsIncluded: parser.HasDMSBindsIncluded(),
DMSStatus: parser.buildDMSStatus(), DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs, ConflictingConfigs: parser.conflictingConfigs,
@@ -7,6 +7,28 @@ import (
"testing" "testing"
) )
func TestNiriParseModKey(t *testing.T) {
config := `input {
mod-key "Alt"
}
binds {
Mod+T { spawn "kitty"; }
}
`
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if result.ModKey != "Alt" {
t.Errorf("ModKey = %q, want %q", result.ModKey, "Alt")
}
}
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) { func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
config := `recent-windows { config := `recent-windows {
binds { binds {
+60 -1
View File
@@ -17,7 +17,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
content := `binds { content := `input {
mod-key "Alt"
}
binds {
Mod+Q { close-window; } Mod+Q { close-window; }
Mod+F { fullscreen-window; } Mod+F { fullscreen-window; }
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
@@ -45,6 +48,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri") t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri")
} }
if cheatSheet.ModKey != "Alt" {
t.Errorf("ModKey = %q, want %q", cheatSheet.ModKey, "Alt")
}
windowBinds := cheatSheet.Binds["Window"] windowBinds := cheatSheet.Binds["Window"]
if len(windowBinds) < 2 { if len(windowBinds) < 2 {
t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds)) t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds))
@@ -227,6 +234,58 @@ func TestNiriGenerateBindsContent(t *testing.T) {
expected: `binds { expected: `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; } Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
} }
`,
},
{
name: "spawn with equals arg",
binds: map[string]*overrideBind{
"Mod+B": {
Key: "Mod+B",
Action: `spawn /opt/browser --profile-directory=Default`,
},
},
expected: `binds {
Mod+B { spawn "/opt/browser" "--profile-directory=Default"; }
}
`,
},
{
name: "spawn shell command with quoted equals args",
binds: map[string]*overrideBind{
"Mod+C": {
Key: "Mod+C",
Action: `spawn sh -c "chrome --profile-directory=Default --app=x"`,
},
},
expected: `binds {
Mod+C { spawn "sh" "-c" "chrome --profile-directory=Default --app=x"; }
}
`,
},
{
name: "spawn env assignment stays arg",
binds: map[string]*overrideBind{
"Mod+E": {
Key: "Mod+E",
Action: `spawn env FOO=bar mycmd`,
},
},
expected: `binds {
Mod+E { spawn "env" "FOO=bar" "mycmd"; }
}
`,
},
{
name: "niri action property remains property",
binds: map[string]*overrideBind{
"Print": {
Key: "Print",
Action: `screenshot show-pointer=false`,
},
},
expected: `binds {
Print { screenshot show-pointer=false; }
}
`, `,
}, },
{ {
+1
View File
@@ -32,6 +32,7 @@ type DMSBindsStatus struct {
type CheatSheet struct { type CheatSheet struct {
Title string `json:"title"` Title string `json:"title"`
Provider string `json:"provider"` Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"`
Binds map[string][]Keybind `json:"binds"` Binds map[string][]Keybind `json:"binds"`
DMSBindsIncluded bool `json:"dmsBindsIncluded"` DMSBindsIncluded bool `json:"dmsBindsIncluded"`
DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"` DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"`
+70 -1
View File
@@ -118,6 +118,50 @@ type ColorsOutput struct {
} `json:"colors"` } `json:"colors"`
} }
type SchemePreview struct {
Dark string `json:"dark"`
Light string `json:"light"`
}
var previewSchemeTypes = []string{
"scheme-tonal-spot",
"scheme-vibrant",
"scheme-content",
"scheme-expressive",
"scheme-fidelity",
"scheme-fruit-salad",
"scheme-monochrome",
"scheme-neutral",
"scheme-rainbow",
}
func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePreview, error) {
if sourceColor == "" {
return nil, fmt.Errorf("source color is required")
}
previews := make(map[string]SchemePreview, len(previewSchemeTypes))
for _, schemeType := range previewSchemeTypes {
output, err := runMatugenDryRun(&Options{
Kind: "hex",
Value: sourceColor,
MatugenType: schemeType,
Contrast: contrast,
})
if err != nil {
return nil, fmt.Errorf("preview %s: %w", schemeType, err)
}
dark := extractMatugenColor(output, "primary", "dark")
light := extractMatugenColor(output, "primary", "light")
if dark == "" || light == "" {
return nil, fmt.Errorf("preview %s: primary colors missing from matugen output", schemeType)
}
previews[schemeType] = SchemePreview{Dark: dark, Light: light}
}
return previews, nil
}
func (o *Options) ColorsOutput() string { func (o *Options) ColorsOutput() string {
return filepath.Join(o.StateDir, "dms-colors.json") return filepath.Join(o.StateDir, "dms-colors.json")
} }
@@ -655,6 +699,7 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
func detectMatugenVersionLocked() (matugenFlags, error) { func detectMatugenVersionLocked() (matugenFlags, error) {
cmd := exec.Command("matugen", "--version") cmd := exec.Command("matugen", "--version")
cmd.Env = utils.EnvWithUserBinPath(nil)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err) return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
@@ -711,6 +756,7 @@ func runMatugen(baseArgs []string) error {
args := buildMatugenArgs(baseArgs, flags) args := buildMatugenArgs(baseArgs, flags)
cmd := exec.Command("matugen", args...) cmd := exec.Command("matugen", args...)
cmd.Env = utils.EnvWithUserBinPath(nil)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
runErr := cmd.Run() runErr := cmd.Run()
@@ -728,6 +774,7 @@ func runMatugen(baseArgs []string) error {
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4) log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
args = buildMatugenArgs(baseArgs, newFlags) args = buildMatugenArgs(baseArgs, newFlags)
retryCmd := exec.Command("matugen", args...) retryCmd := exec.Command("matugen", args...)
retryCmd.Env = utils.EnvWithUserBinPath(nil)
retryCmd.Stdout = os.Stdout retryCmd.Stdout = os.Stdout
retryCmd.Stderr = os.Stderr retryCmd.Stderr = os.Stderr
return retryCmd.Run() return retryCmd.Run()
@@ -770,6 +817,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
} }
cmd := exec.Command("matugen", baseArgs...) cmd := exec.Command("matugen", baseArgs...)
cmd.Env = utils.EnvWithUserBinPath(nil)
var stderr strings.Builder var stderr strings.Builder
cmd.Stderr = &stderr cmd.Stderr = &stderr
output, err := cmd.Output() output, err := cmd.Output()
@@ -868,7 +916,26 @@ func refreshGTK(mode ColorMode) {
} }
} }
var colorSchemeEchoHook func(scheme string)
func SetColorSchemeEchoHook(hook func(scheme string)) {
colorSchemeEchoHook = hook
}
func expectColorSchemeEcho(scheme string) {
if colorSchemeEchoHook != nil {
colorSchemeEchoHook(scheme)
}
}
// The color-scheme round trip is the only mechanism that makes running GTK4
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
// following the portal color-scheme (Chromium) can drop the restore signal
// mid-repaint and latch the wrong mode, so this is opt-in.
func refreshGTK4() { func refreshGTK4() {
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
return
}
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme") output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
if err != nil { if err != nil {
return return
@@ -882,11 +949,13 @@ func refreshGTK4() {
toggle = "prefer-dark" toggle = "prefer-dark"
} }
expectColorSchemeEcho(toggle)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil { if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err) log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
return return
} }
time.Sleep(50 * time.Millisecond) time.Sleep(400 * time.Millisecond)
expectColorSchemeEcho(current)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil { if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err) log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
} }
+23 -8
View File
@@ -554,35 +554,50 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
return "", nil return "", nil
} }
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) { func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
pluginPath, err := m.findInstalledPath(pluginID) pluginPath, err := m.findInstalledPath(pluginID)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to find plugin: %w", err) return false, "", fmt.Errorf("failed to find plugin: %w", err)
} }
if pluginPath == "" { if pluginPath == "" {
return false, fmt.Errorf("plugin not installed: %s", pluginID) return false, "", fmt.Errorf("plugin not installed: %s", pluginID)
} }
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return false, nil return false, "", nil
} }
metaPath := pluginPath + ".meta" metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath) metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to check metadata: %w", err) return false, "", fmt.Errorf("failed to check metadata: %w", err)
} }
var hasUp bool
var localHash, remoteHash string
if metaExists { if metaExists {
// Plugin is from a monorepo, check the repo directory // Plugin is from a monorepo, check the repo directory
reposDir := filepath.Join(m.pluginsDir, ".repos") reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo) repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName) repoPath := filepath.Join(reposDir, repoName)
return m.gitClient.HasUpdates(repoPath) hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
} else {
// Plugin is a standalone repo
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
} }
// Plugin is a standalone repo if err != nil {
return m.gitClient.HasUpdates(pluginPath) return false, "", err
}
diffURL = plugin.Repo
if diffURL != "" {
diffURL = strings.TrimSuffix(diffURL, ".git")
if hasUp && len(localHash) >= 7 && len(remoteHash) >= 7 {
diffURL = fmt.Sprintf("%s/compare/%s...%s", diffURL, localHash[:7], remoteHash[:7])
}
}
return hasUp, diffURL, nil
} }
+10 -9
View File
@@ -33,7 +33,7 @@ type Plugin struct {
type GitClient interface { type GitClient interface {
PlainClone(path string, url string) error PlainClone(path string, url string) error
Pull(path string) error Pull(path string) error
HasUpdates(path string) (bool, error) HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
} }
type realGitClient struct{} type realGitClient struct{}
@@ -65,10 +65,10 @@ func (g *realGitClient) Pull(path string) error {
return nil return nil
} }
func (g *realGitClient) HasUpdates(path string) (bool, error) { func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
repo, err := git.PlainOpen(path) repo, err := git.PlainOpen(path)
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
// Fetch remote changes // Fetch remote changes
@@ -76,24 +76,24 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
if err != nil && err.Error() != "already up-to-date" { if err != nil && err.Error() != "already up-to-date" {
// If fetch fails, we can't determine if there are updates // If fetch fails, we can't determine if there are updates
// Return false and the error // Return false and the error
return false, err return false, "", "", err
} }
// Get the HEAD reference // Get the HEAD reference
head, err := repo.Head() head, err := repo.Head()
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
// Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master) // Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
remote, err := repo.Remote("origin") remote, err := repo.Remote("origin")
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
refs, err := remote.List(&git.ListOptions{}) refs, err := remote.List(&git.ListOptions{})
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
// Find the default branch remote ref // Find the default branch remote ref
@@ -108,13 +108,14 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
} }
} }
localHash := head.Hash().String()
// If we couldn't find a remote HEAD, assume no updates // If we couldn't find a remote HEAD, assume no updates
if remoteHead == "" { if remoteHead == "" {
return false, nil return false, localHash, "", nil
} }
// Compare local HEAD with remote HEAD // Compare local HEAD with remote HEAD
return head.Hash().String() != remoteHead, nil return localHash != remoteHead, localHash, remoteHead, nil
} }
type Registry struct { type Registry struct {
+3 -3
View File
@@ -13,7 +13,7 @@ import (
type mockGitClient struct { type mockGitClient struct {
cloneFunc func(path string, url string) error cloneFunc func(path string, url string) error
pullFunc func(path string) error pullFunc func(path string) error
hasUpdatesFunc func(path string) (bool, error) hasUpdatesFunc func(path string) (bool, string, string, error)
} }
func (m *mockGitClient) PlainClone(path string, url string) error { func (m *mockGitClient) PlainClone(path string, url string) error {
@@ -30,11 +30,11 @@ func (m *mockGitClient) Pull(path string) error {
return nil return nil
} }
func (m *mockGitClient) HasUpdates(path string) (bool, error) { func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
if m.hasUpdatesFunc != nil { if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path) return m.hasUpdatesFunc(path)
} }
return false, nil return false, "", "", nil
} }
func TestNewRegistry(t *testing.T) { func TestNewRegistry(t *testing.T) {
@@ -0,0 +1,289 @@
// Generated by go-wayland-scanner
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
// XML file : internal/proto/xml/virtual-keyboard-unstable-v1.xml
//
// virtual_keyboard_unstable_v1 Protocol Copyright:
//
// Copyright © 2008-2011 Kristian Høgsberg
// Copyright © 2010-2013 Intel Corporation
// Copyright © 2012-2013 Collabora, Ltd.
// Copyright © 2018 Purism SPC
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice (including the next
// paragraph) shall be included in all copies or substantial portions of the
// Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
package virtual_keyboard
import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
// ZwpVirtualKeyboardV1InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZwpVirtualKeyboardV1InterfaceName = "zwp_virtual_keyboard_v1"
// ZwpVirtualKeyboardV1 : virtual keyboard
//
// The virtual keyboard provides an application with requests which emulate
// the behaviour of a physical keyboard.
//
// This interface can be used by clients on its own to provide raw input
// events, or it can accompany the input method protocol.
type ZwpVirtualKeyboardV1 struct {
client.BaseProxy
}
// NewZwpVirtualKeyboardV1 : virtual keyboard
//
// The virtual keyboard provides an application with requests which emulate
// the behaviour of a physical keyboard.
//
// This interface can be used by clients on its own to provide raw input
// events, or it can accompany the input method protocol.
func NewZwpVirtualKeyboardV1(ctx *client.Context) *ZwpVirtualKeyboardV1 {
zwpVirtualKeyboardV1 := &ZwpVirtualKeyboardV1{}
ctx.Register(zwpVirtualKeyboardV1)
return zwpVirtualKeyboardV1
}
// Keymap : keyboard mapping
//
// Provide a file descriptor to the compositor which can be
// memory-mapped to provide a keyboard mapping description.
//
// Format carries a value from the keymap_format enumeration.
//
// format: keymap format
// fd: keymap file descriptor
// size: keymap size, in bytes
func (i *ZwpVirtualKeyboardV1) Keymap(format uint32, fd int, size uint32) error {
const opcode = 0
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(format))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(size))
l += 4
oob := unix.UnixRights(int(fd))
err := i.Context().WriteMsg(_reqBuf[:], oob)
return err
}
// Key : key event
//
// A key was pressed or released.
// The time argument is a timestamp with millisecond granularity, with an
// undefined base. All requests regarding a single object must share the
// same clock.
//
// Keymap must be set before issuing this request.
//
// State carries a value from the key_state enumeration.
//
// time: timestamp with millisecond granularity
// key: key that produced the event
// state: physical state of the key
func (i *ZwpVirtualKeyboardV1) Key(time, key, state uint32) error {
const opcode = 1
const _reqBufLen = 8 + 4 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(time))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(key))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(state))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// Modifiers : modifier and group state
//
// Notifies the compositor that the modifier and/or group state has
// changed, and it should update state.
//
// The client should use wl_keyboard.modifiers event to synchronize its
// internal state with seat state.
//
// Keymap must be set before issuing this request.
//
// modsDepressed: depressed modifiers
// modsLatched: latched modifiers
// modsLocked: locked modifiers
// group: keyboard layout
func (i *ZwpVirtualKeyboardV1) Modifiers(modsDepressed, modsLatched, modsLocked, group uint32) error {
const opcode = 2
const _reqBufLen = 8 + 4 + 4 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(modsDepressed))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(modsLatched))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(modsLocked))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(group))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// Destroy : destroy the virtual keyboard keyboard object
func (i *ZwpVirtualKeyboardV1) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 3
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
type ZwpVirtualKeyboardV1Error uint32
// ZwpVirtualKeyboardV1Error :
const (
// ZwpVirtualKeyboardV1ErrorNoKeymap : No keymap was set
ZwpVirtualKeyboardV1ErrorNoKeymap ZwpVirtualKeyboardV1Error = 0
)
func (e ZwpVirtualKeyboardV1Error) Name() string {
switch e {
case ZwpVirtualKeyboardV1ErrorNoKeymap:
return "no_keymap"
default:
return ""
}
}
func (e ZwpVirtualKeyboardV1Error) Value() string {
switch e {
case ZwpVirtualKeyboardV1ErrorNoKeymap:
return "0"
default:
return ""
}
}
func (e ZwpVirtualKeyboardV1Error) String() string {
return e.Name() + "=" + e.Value()
}
// ZwpVirtualKeyboardManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZwpVirtualKeyboardManagerV1InterfaceName = "zwp_virtual_keyboard_manager_v1"
// ZwpVirtualKeyboardManagerV1 : virtual keyboard manager
//
// A virtual keyboard manager allows an application to provide keyboard
// input events as if they came from a physical keyboard.
type ZwpVirtualKeyboardManagerV1 struct {
client.BaseProxy
}
// NewZwpVirtualKeyboardManagerV1 : virtual keyboard manager
//
// A virtual keyboard manager allows an application to provide keyboard
// input events as if they came from a physical keyboard.
func NewZwpVirtualKeyboardManagerV1(ctx *client.Context) *ZwpVirtualKeyboardManagerV1 {
zwpVirtualKeyboardManagerV1 := &ZwpVirtualKeyboardManagerV1{}
ctx.Register(zwpVirtualKeyboardManagerV1)
return zwpVirtualKeyboardManagerV1
}
// CreateVirtualKeyboard : Create a new virtual keyboard
//
// Creates a new virtual keyboard associated to a seat.
//
// If the compositor enables a keyboard to perform arbitrary actions, it
// should present an error when an untrusted client requests a new
// keyboard.
func (i *ZwpVirtualKeyboardManagerV1) CreateVirtualKeyboard(seat *client.Seat) (*ZwpVirtualKeyboardV1, error) {
id := NewZwpVirtualKeyboardV1(i.Context())
const opcode = 0
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], seat.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], id.ID())
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return id, err
}
func (i *ZwpVirtualKeyboardManagerV1) Destroy() error {
i.Context().Unregister(i)
return nil
}
type ZwpVirtualKeyboardManagerV1Error uint32
// ZwpVirtualKeyboardManagerV1Error :
const (
// ZwpVirtualKeyboardManagerV1ErrorUnauthorized : client not authorized to use the interface
ZwpVirtualKeyboardManagerV1ErrorUnauthorized ZwpVirtualKeyboardManagerV1Error = 0
)
func (e ZwpVirtualKeyboardManagerV1Error) Name() string {
switch e {
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
return "unauthorized"
default:
return ""
}
}
func (e ZwpVirtualKeyboardManagerV1Error) Value() string {
switch e {
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
return "0"
default:
return ""
}
}
func (e ZwpVirtualKeyboardManagerV1Error) String() string {
return e.Name() + "=" + e.Value()
}
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="virtual_keyboard_unstable_v1">
<copyright>
Copyright © 2008-2011 Kristian Høgsberg
Copyright © 2010-2013 Intel Corporation
Copyright © 2012-2013 Collabora, Ltd.
Copyright © 2018 Purism SPC
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="zwp_virtual_keyboard_v1" version="1">
<description summary="virtual keyboard">
The virtual keyboard provides an application with requests which emulate
the behaviour of a physical keyboard.
This interface can be used by clients on its own to provide raw input
events, or it can accompany the input method protocol.
</description>
<request name="keymap">
<description summary="keyboard mapping">
Provide a file descriptor to the compositor which can be
memory-mapped to provide a keyboard mapping description.
Format carries a value from the keymap_format enumeration.
</description>
<arg name="format" type="uint" summary="keymap format"/>
<arg name="fd" type="fd" summary="keymap file descriptor"/>
<arg name="size" type="uint" summary="keymap size, in bytes"/>
</request>
<enum name="error">
<entry name="no_keymap" value="0" summary="No keymap was set"/>
</enum>
<request name="key">
<description summary="key event">
A key was pressed or released.
The time argument is a timestamp with millisecond granularity, with an
undefined base. All requests regarding a single object must share the
same clock.
Keymap must be set before issuing this request.
State carries a value from the key_state enumeration.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="key" type="uint" summary="key that produced the event"/>
<arg name="state" type="uint" summary="physical state of the key"/>
</request>
<request name="modifiers">
<description summary="modifier and group state">
Notifies the compositor that the modifier and/or group state has
changed, and it should update state.
The client should use wl_keyboard.modifiers event to synchronize its
internal state with seat state.
Keymap must be set before issuing this request.
</description>
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
<arg name="group" type="uint" summary="keyboard layout"/>
</request>
<request name="destroy" type="destructor" since="1">
<description summary="destroy the virtual keyboard keyboard object"/>
</request>
</interface>
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
<description summary="virtual keyboard manager">
A virtual keyboard manager allows an application to provide keyboard
input events as if they came from a physical keyboard.
</description>
<enum name="error">
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
</enum>
<request name="create_virtual_keyboard">
<description summary="Create a new virtual keyboard">
Creates a new virtual keyboard associated to a seat.
If the compositor enables a keyboard to perform arbitrary actions, it
should present an error when an untrusted client requests a new
keyboard.
</description>
<arg name="seat" type="object" interface="wl_seat"/>
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
</request>
</interface>
</protocol>
+8 -1
View File
@@ -68,6 +68,12 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
activeBuses[i] = true activeBuses[i] = true
id := fmt.Sprintf("ddc:i2c-%d", i) id := fmt.Sprintf("ddc:i2c-%d", i)
// Don't re-probe identified monitors: DDC traffic during a wake
// sequence can disturb some monitors' own brightness handling.
if _, ok := b.devices.Load(id); ok {
continue
}
dev, err := b.probeDDCDevice(i) dev, err := b.probeDDCDevice(i)
if err != nil || dev == nil { if err != nil || dev == nil {
continue continue
@@ -107,7 +113,8 @@ func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
dummy := make([]byte, 32) dummy := make([]byte, 32)
syscall.Read(fd, dummy) //nolint:errcheck syscall.Read(fd, dummy) //nolint:errcheck
writebuf := []byte{0x00} writebuf := []byte{DDC_SOURCE_ADDR, 0x80}
writebuf = append(writebuf, ddcciChecksum(writebuf))
n, err := syscall.Write(fd, writebuf) n, err := syscall.Write(fd, writebuf)
if err == nil && n == len(writebuf) { if err == nil && n == len(writebuf) {
name := b.getDDCName(bus) name := b.getDDCName(bus)
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net" "net"
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
) )
@@ -28,6 +29,10 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
handleCopyEntry(conn, req, m) handleCopyEntry(conn, req, m)
case "clipboard.paste": case "clipboard.paste":
handlePaste(conn, req, m) handlePaste(conn, req, m)
case "clipboard.sendPaste":
handleSendPaste(conn, req)
case "clipboard.pasteSupported":
models.Respond(conn, req.ID, map[string]bool{"supported": m.pasteSupported})
case "clipboard.subscribe": case "clipboard.subscribe":
handleSubscribe(conn, req, m) handleSubscribe(conn, req, m)
case "clipboard.search": case "clipboard.search":
@@ -176,6 +181,17 @@ func handlePaste(conn net.Conn, req models.Request, m *Manager) {
models.Respond(conn, req.ID, map[string]string{"text": text}) models.Respond(conn, req.ID, map[string]string{"text": text})
} }
func handleSendPaste(conn net.Conn, req models.Request) {
shift, _ := models.Get[bool](req, "shift")
if err := clipboardstore.SendPasteKeystroke(shift); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "paste sent"})
}
func handleSubscribe(conn net.Conn, req models.Request, m *Manager) { func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
clientID := fmt.Sprintf("clipboard-%d", req.ID) clientID := fmt.Sprintf("clipboard-%d", req.ID)
+31 -76
View File
@@ -31,6 +31,7 @@ import (
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
@@ -164,6 +165,8 @@ func (m *Manager) setupRegistry() error {
m.seat = seat m.seat = seat
m.seatName = e.Name m.seatName = e.Name
log.Info("Bound wl_seat") log.Info("Bound wl_seat")
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
m.pasteSupported = true
} }
}) })
@@ -1054,6 +1057,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
dataCopy := make([]byte, len(data)) dataCopy := make([]byte, len(data))
copy(dataCopy, data) copy(dataCopy, data)
m.takeSelection(clipboardstore.ExpandOffers(dataCopy, mimeType))
return nil
}
// takeSelection makes the daemon the selection owner, serving the given
// offers until another client claims the clipboard.
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
m.post(func() { m.post(func() {
if m.dataControlMgr == nil || m.dataDevice == nil { if m.dataControlMgr == nil || m.dataDevice == nil {
log.Error("Data control manager or device not initialized") log.Error("Data control manager or device not initialized")
@@ -1068,9 +1078,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
return return
} }
if err := source.Offer(mimeType); err != nil { offerData := make(map[string][]byte, len(offers))
log.Errorf("Failed to offer mime type: %v", err) for _, offer := range offers {
return if err := source.Offer(offer.MimeType); err != nil {
log.Errorf("Failed to offer %s: %v", offer.MimeType, err)
return
}
offerData[offer.MimeType] = offer.Data
} }
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
@@ -1080,7 +1094,11 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
file := os.NewFile(uintptr(fd), "clipboard-pipe") file := os.NewFile(uintptr(fd), "clipboard-pipe")
defer file.Close() defer file.Close()
if _, err := file.Write(dataCopy); err != nil { data, ok := offerData[e.MimeType]
if !ok {
return
}
if _, err := file.Write(data); err != nil {
log.Errorf("Failed to write clipboard data: %v", err) log.Errorf("Failed to write clipboard data: %v", err)
} }
}) })
@@ -1093,9 +1111,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
m.releaseCurrentSource() m.releaseCurrentSource()
m.currentSource = source m.currentSource = source
m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType}
m.sourceMutex.Unlock()
m.ownerLock.Lock() m.ownerLock.Lock()
m.isOwner = true m.isOwner = true
@@ -1106,8 +1121,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
log.Errorf("Failed to set selection: %v", err) log.Errorf("Failed to set selection: %v", err)
} }
}) })
return nil
} }
func (m *Manager) CopyText(text string) error { func (m *Manager) CopyText(text string) error {
@@ -1779,74 +1792,16 @@ func (m *Manager) CopyFile(filePath string) error {
m.updateState() m.updateState()
m.notifySubscribers() m.notifySubscribers()
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData)) offers := []clipboardstore.Offer{
{MimeType: "x-special/gnome-copied-files", Data: []byte("copy\n" + fileURI)},
m.post(func() { {MimeType: "text/uri-list", Data: []byte(fileURI + "\r\n")},
if m.dataControlMgr == nil || m.dataDevice == nil { {MimeType: "text/plain", Data: []byte(filePath)},
log.Error("Data control manager or device not initialized") }
return if _, imgMime, err := image.DecodeConfig(bytes.NewReader(fileData)); err == nil {
} offers = append(offers, clipboardstore.Offer{MimeType: "image/" + imgMime, Data: fileData})
}
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
source, err := dataMgr.CreateDataSource()
if err != nil {
log.Errorf("Failed to create data source: %v", err)
return
}
type offer struct {
mime string
data []byte
}
offers := []offer{
{"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
{"text/uri-list", []byte(fileURI + "\r\n")},
{"text/plain", []byte(filePath)},
}
if imgErr == nil {
imgMimeType := "image/" + imgMime
offers = append(offers, offer{imgMimeType, fileData})
}
offerData := make(map[string][]byte)
for _, o := range offers {
if err := source.Offer(o.mime); err != nil {
log.Errorf("Failed to offer %s: %v", o.mime, err)
return
}
offerData[o.mime] = o.data
}
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
fd := e.Fd
defer syscall.Close(fd)
file := os.NewFile(uintptr(fd), "clipboard-pipe")
defer file.Close()
if data, ok := offerData[e.MimeType]; ok {
file.Write(data)
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.releaseCurrentSource()
m.currentSource = source
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
}
})
m.takeSelection(offers)
return nil return nil
} }
@@ -531,47 +531,6 @@ func TestManager_ConcurrentOfferAccess(t *testing.T) {
wg.Wait() wg.Wait()
} }
func TestManager_ConcurrentPersistAccess(t *testing.T) {
m := &Manager{
persistData: make(map[string][]byte),
persistMimeTypes: []string{},
}
var wg sync.WaitGroup
const goroutines = 20
const iterations = 50
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.persistMutex.RLock()
_ = m.persistData
_ = m.persistMimeTypes
m.persistMutex.RUnlock()
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.persistMutex.Lock()
m.persistMimeTypes = []string{"text/plain", "text/html"}
m.persistData = map[string][]byte{
"text/plain": []byte("test"),
}
m.persistMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_ConcurrentOwnerAccess(t *testing.T) { func TestManager_ConcurrentOwnerAccess(t *testing.T) {
m := &Manager{} m := &Manager{}
+3 -9
View File
@@ -132,15 +132,9 @@ type Manager struct {
offerMutex sync.RWMutex offerMutex sync.RWMutex
offerRegistry map[uint32]any offerRegistry map[uint32]any
sourceMimeTypes []string isOwner bool
sourceMutex sync.RWMutex ownerLock sync.Mutex
pasteSupported bool
persistData map[string][]byte
persistMimeTypes []string
persistMutex sync.RWMutex
isOwner bool
ownerLock sync.Mutex
initialized bool initialized bool
+39 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"sync" "sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
@@ -100,6 +101,41 @@ func (m *Manager) initializeSettings() error {
return nil return nil
} }
// ExpectColorSchemeEcho registers a self-write so the watcher swallows its SettingChanged echo instead of forwarding it as an external change.
func (m *Manager) ExpectColorSchemeEcho(scheme string) {
var value uint32
switch scheme {
case "prefer-dark":
value = 1
case "prefer-light":
value = 2
}
m.selfEchoMu.Lock()
m.selfEchoes = append(m.selfEchoes, colorSchemeEcho{value: value, expires: time.Now().Add(10 * time.Second)})
m.selfEchoMu.Unlock()
}
func (m *Manager) consumeSelfEcho(value uint32) bool {
m.selfEchoMu.Lock()
defer m.selfEchoMu.Unlock()
now := time.Now()
kept := m.selfEchoes[:0]
consumed := false
for _, echo := range m.selfEchoes {
if now.After(echo.expires) {
continue
}
if !consumed && echo.value == value {
consumed = true
continue
}
kept = append(kept, echo)
}
m.selfEchoes = kept
return consumed
}
func (m *Manager) watchSettingsChanges() { func (m *Manager) watchSettingsChanges() {
conn, err := dbus.ConnectSessionBus() conn, err := dbus.ConnectSessionBus()
if err != nil { if err != nil {
@@ -142,13 +178,15 @@ func (m *Manager) watchSettingsChanges() {
continue continue
} }
selfInflicted := m.consumeSelfEcho(colorScheme)
m.stateMutex.Lock() m.stateMutex.Lock()
changed := m.state.Settings.ColorScheme != colorScheme || !m.state.Settings.Available changed := m.state.Settings.ColorScheme != colorScheme || !m.state.Settings.Available
m.state.Settings.ColorScheme = colorScheme m.state.Settings.ColorScheme = colorScheme
m.state.Settings.Available = true m.state.Settings.Available = true
m.stateMutex.Unlock() m.stateMutex.Unlock()
if changed { if changed && !selfInflicted {
m.NotifySubscribers() m.NotifySubscribers()
} }
} }
@@ -141,3 +141,45 @@ func TestManager_SettingsState_Modification(t *testing.T) {
original := manager.GetState() original := manager.GetState()
assert.Equal(t, uint32(0), original.Settings.ColorScheme) assert.Equal(t, uint32(0), original.Settings.ColorScheme)
} }
func TestManager_SelfEcho_ConsumesRegisteredWrites(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-dark")
manager.ExpectColorSchemeEcho("default")
assert.True(t, manager.consumeSelfEcho(1))
assert.True(t, manager.consumeSelfEcho(0))
assert.False(t, manager.consumeSelfEcho(1))
assert.False(t, manager.consumeSelfEcho(0))
}
func TestManager_SelfEcho_ExternalChangePassesThrough(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-dark")
assert.False(t, manager.consumeSelfEcho(2))
assert.True(t, manager.consumeSelfEcho(1))
}
func TestManager_SelfEcho_ConsumesOnePerRegistration(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-dark")
manager.ExpectColorSchemeEcho("prefer-dark")
assert.True(t, manager.consumeSelfEcho(1))
assert.True(t, manager.consumeSelfEcho(1))
assert.False(t, manager.consumeSelfEcho(1))
}
func TestManager_SelfEcho_SchemeMapping(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-light")
assert.True(t, manager.consumeSelfEcho(2))
manager.ExpectColorSchemeEcho("default")
assert.True(t, manager.consumeSelfEcho(0))
}
@@ -2,11 +2,17 @@ package freedesktop
import ( import (
"sync" "sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
type colorSchemeEcho struct {
value uint32
expires time.Time
}
type AccountsState struct { type AccountsState struct {
Available bool `json:"available"` Available bool `json:"available"`
UserPath string `json:"userPath"` UserPath string `json:"userPath"`
@@ -63,4 +69,6 @@ type Manager struct {
screensaverCookieCounter uint32 screensaverCookieCounter uint32
screensaverFreedesktopClaimed bool screensaverFreedesktopClaimed bool
screensaverGnomeClaimed bool screensaverGnomeClaimed bool
selfEchoMu sync.Mutex
selfEchoes []colorSchemeEcho
} }
+13
View File
@@ -46,6 +46,19 @@ func (m *Manager) Activate() error {
return nil return nil
} }
func (m *Manager) SetLockedHint(locked bool) error {
err := m.sessionObj.Call(dbusSessionInterface+".SetLockedHint", 0, locked).Err
if err != nil {
if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
err = m.sessionObj.Call(dbusSessionInterface+".SetLockedHint", 0, locked).Err
}
if err != nil {
return fmt.Errorf("failed to set locked hint: %w", err)
}
}
return nil
}
func (m *Manager) SetIdleHint(idle bool) error { func (m *Manager) SetIdleHint(idle bool) error {
err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
if err != nil { if err != nil {
+16
View File
@@ -21,6 +21,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleActivate(conn, req, manager) handleActivate(conn, req, manager)
case "loginctl.setIdleHint": case "loginctl.setIdleHint":
handleSetIdleHint(conn, req, manager) handleSetIdleHint(conn, req, manager)
case "loginctl.setLockedHint":
handleSetLockedHint(conn, req, manager)
case "loginctl.setLockBeforeSuspend": case "loginctl.setLockBeforeSuspend":
handleSetLockBeforeSuspend(conn, req, manager) handleSetLockBeforeSuspend(conn, req, manager)
case "loginctl.setSleepInhibitorEnabled": case "loginctl.setSleepInhibitorEnabled":
@@ -78,6 +80,20 @@ func handleSetIdleHint(conn net.Conn, req models.Request, manager *Manager) {
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"})
} }
func handleSetLockedHint(conn net.Conn, req models.Request, manager *Manager) {
locked, err := params.Bool(req.Params, "locked")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := manager.SetLockedHint(locked); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "locked hint set"})
}
func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) { func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) {
enabled, err := params.Bool(req.Params, "enabled") enabled, err := params.Bool(req.Params, "enabled")
if err != nil { if err != nil {
+25 -1
View File
@@ -114,6 +114,14 @@ func (b *IWDBackend) discoverDevices() error {
return fmt.Errorf("failed to get managed objects: %w", err) return fmt.Errorf("failed to get managed objects: %w", err)
} }
return b.applyManagedObjects(objects)
}
func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant) error {
b.stationPath = ""
b.devicePath = ""
b.adapterPath = ""
for path, interfaces := range objects { for path, interfaces := range objects {
if _, hasStation := interfaces[iwdStationInterface]; hasStation { if _, hasStation := interfaces[iwdStationInterface]; hasStation {
b.stationPath = path b.stationPath = path
@@ -129,6 +137,13 @@ func (b *IWDBackend) discoverDevices() error {
b.stateMutex.Unlock() b.stateMutex.Unlock()
} }
} }
if poweredVar, ok := devProps["Powered"]; ok {
if powered, ok := poweredVar.Value().(bool); ok {
b.stateMutex.Lock()
b.state.WiFiEnabled = powered
b.stateMutex.Unlock()
}
}
} }
} }
if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter { if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
@@ -136,9 +151,18 @@ func (b *IWDBackend) discoverDevices() error {
} }
} }
if b.stationPath == "" || b.devicePath == "" { if b.devicePath == "" {
return fmt.Errorf("no WiFi device found") return fmt.Errorf("no WiFi device found")
} }
if b.stationPath == "" {
b.stateMutex.Lock()
b.state.WiFiEnabled = false
b.state.WiFiConnected = false
b.state.NetworkStatus = StatusDisconnected
b.state.WiFiNetworks = nil
b.stateMutex.Unlock()
log.Infof("iwd device %s has no station interface; treating WiFi as disabled", b.devicePath)
}
return nil return nil
} }
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -35,12 +36,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
} }
if b.stationPath != "" { if b.stationPath != "" {
err := b.conn.AddMatchSignal( if err := b.addStationSignalMatch(b.stationPath); err != nil {
dbus.WithMatchObjectPath(b.stationPath),
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
if err != nil {
return fmt.Errorf("failed to add station signal match: %w", err) return fmt.Errorf("failed to add station signal match: %w", err)
} }
} }
@@ -81,6 +77,48 @@ func (b *IWDBackend) refreshWiFiNetworkState() bool {
return b.updateSavedWiFiNetworks() == nil return b.updateSavedWiFiNetworks() == nil
} }
func (b *IWDBackend) addStationSignalMatch(path dbus.ObjectPath) error {
return b.conn.AddMatchSignal(
dbus.WithMatchObjectPath(path),
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
func (b *IWDBackend) handleStationAdded(path dbus.ObjectPath) bool {
if path == "" || path == b.stationPath {
return false
}
b.stationPath = path
if err := b.addStationSignalMatch(path); err != nil {
log.Warnf("Failed to add iwd station signal match for %s: %v", path, err)
}
if err := b.updateState(); err != nil {
log.Warnf("Failed to update iwd state after station appeared: %v", err)
}
return b.refreshWiFiNetworkState()
}
func (b *IWDBackend) handleStationRemoved(path dbus.ObjectPath) bool {
if path == "" || path != b.stationPath {
return false
}
b.stationPath = ""
b.stateMutex.Lock()
b.state.WiFiEnabled = false
b.state.WiFiConnected = false
b.state.WiFiSSID = ""
b.state.WiFiSignal = 0
b.state.NetworkStatus = StatusDisconnected
b.state.WiFiNetworks = nil
b.stateMutex.Unlock()
return true
}
func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
defer b.sigWG.Done() defer b.sigWG.Done()
@@ -98,7 +136,13 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
if sig.Name == dbusObjectManager+".InterfacesAdded" { if sig.Name == dbusObjectManager+".InterfacesAdded" {
if len(sig.Body) >= 2 { if len(sig.Body) >= 2 {
path, _ := sig.Body[0].(dbus.ObjectPath)
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok { if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
if _, ok := interfaces[iwdStationInterface]; ok {
if b.handleStationAdded(path) && b.onStateChange != nil {
b.onStateChange()
}
}
if _, ok := interfaces[iwdKnownNetworkInterface]; ok { if _, ok := interfaces[iwdKnownNetworkInterface]; ok {
if b.refreshWiFiNetworkState() && b.onStateChange != nil { if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange() b.onStateChange()
@@ -111,8 +155,15 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
if sig.Name == dbusObjectManager+".InterfacesRemoved" { if sig.Name == dbusObjectManager+".InterfacesRemoved" {
if len(sig.Body) >= 2 { if len(sig.Body) >= 2 {
path, _ := sig.Body[0].(dbus.ObjectPath)
if interfaces, ok := sig.Body[1].([]string); ok { if interfaces, ok := sig.Body[1].([]string); ok {
for _, iface := range interfaces { for _, iface := range interfaces {
if iface == iwdStationInterface {
if b.handleStationRemoved(path) && b.onStateChange != nil {
b.onStateChange()
}
break
}
if iface == iwdKnownNetworkInterface { if iface == iwdKnownNetworkInterface {
if b.refreshWiFiNetworkState() && b.onStateChange != nil { if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange() b.onStateChange()
@@ -169,6 +169,41 @@ func TestIWDBackend_MapIwdDBusError(t *testing.T) {
} }
} }
func TestIWDBackend_ApplyManagedObjects_DeviceWithoutStation(t *testing.T) {
backend, _ := NewIWDBackend()
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/0/1": {
iwdDeviceInterface: {
"Name": dbus.MakeVariant("wlan0"),
"Powered": dbus.MakeVariant(false),
},
},
})
assert.NoError(t, err)
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/1"), backend.devicePath)
assert.Empty(t, backend.stationPath)
state, err := backend.GetCurrentState()
assert.NoError(t, err)
assert.Equal(t, "wlan0", state.WiFiDevice)
assert.False(t, state.WiFiEnabled)
assert.False(t, state.WiFiConnected)
}
func TestIWDBackend_ApplyManagedObjects_NoDevice(t *testing.T) {
backend, _ := NewIWDBackend()
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/0": {
iwdAdapterInterface: {},
},
})
assert.ErrorContains(t, err, "no WiFi device found")
}
func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) { func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) {
objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{ objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/known_network/1": { "/net/connman/iwd/known_network/1": {
@@ -42,12 +42,15 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
for _, id := range installedNames { for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok { if plugin, ok := pluginMap[id]; ok {
hasUpdate := false hasUpdate := false
if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil { diffURL := plugin.Repo
if hasUpdates, dURL, err := manager.HasUpdates(id, plugin); err == nil {
hasUpdate = hasUpdates hasUpdate = hasUpdates
diffURL = dURL
} }
info := pluginInfoFromPlugin(plugin) info := pluginInfoFromPlugin(plugin)
info.HasUpdate = hasUpdate info.HasUpdate = hasUpdate
info.DiffURL = diffURL
result = append(result, info) result = append(result, info)
} else { } else {
result = append(result, PluginInfo{ result = append(result, PluginInfo{
+1
View File
@@ -18,6 +18,7 @@ type PluginInfo struct {
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
HasUpdate bool `json:"hasUpdate,omitempty"` HasUpdate bool `json:"hasUpdate,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"` RequiresDMS string `json:"requires_dms,omitempty"`
DiffURL string `json:"diffUrl,omitempty"`
Upvotes int `json:"upvotes,omitempty"` Upvotes int `json:"upvotes,omitempty"`
Status []string `json:"status,omitempty"` Status []string `json:"status,omitempty"`
IssueURL string `json:"issueUrl,omitempty"` IssueURL string `json:"issueUrl,omitempty"`
+2
View File
@@ -17,6 +17,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
@@ -190,6 +191,7 @@ func InitializeFreedeskManager() error {
} }
freedesktopManager = manager freedesktopManager = manager
matugen.SetColorSchemeEchoHook(manager.ExpectColorSchemeEcho)
log.Info("Freedesktop manager initialized") log.Info("Freedesktop manager initialized")
return nil return nil
+62 -13
View File
@@ -16,6 +16,10 @@ type activeSchedule struct {
nextFire time.Time nextFire time.Time
} }
// Grace period before a missed-while-off schedule fires, so a freshly
// connected frontend has its cycle-seq baseline before the bump arrives.
var catchUpDelay = 3 * time.Second
type Manager struct { type Manager struct {
config Config config Config
configMutex sync.RWMutex configMutex sync.RWMutex
@@ -29,9 +33,20 @@ type Manager struct {
updateTrigger chan struct{} updateTrigger chan struct{}
resetReq chan string resetReq chan string
wg sync.WaitGroup wg sync.WaitGroup
// Owned by schedulerLoop after start; keyed like schedules ("" = global).
lastFires map[string]time.Time
persistFires func(map[string]time.Time)
} }
func NewManager() *Manager { func NewManager() *Manager {
return newManager(loadLastFires(), saveLastFires)
}
func newManager(lastFires map[string]time.Time, persistFires func(map[string]time.Time)) *Manager {
if lastFires == nil {
lastFires = map[string]time.Time{}
}
m := &Manager{ m := &Manager{
config: Config{ config: Config{
Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"}, Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"},
@@ -40,6 +55,8 @@ func NewManager() *Manager {
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
updateTrigger: make(chan struct{}, 1), updateTrigger: make(chan struct{}, 1),
resetReq: make(chan string, 8), resetReq: make(chan string, 8),
lastFires: lastFires,
persistFires: persistFires,
} }
m.state = &State{Config: m.getConfig()} m.state = &State{Config: m.getConfig()}
@@ -115,9 +132,7 @@ func (m *Manager) Close() {
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) { func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
ch := lm.Subscribe("wallpaper") ch := lm.Subscribe("wallpaper")
m.wg.Add(1) m.wg.Go(func() {
go func() {
defer m.wg.Done()
defer lm.Unsubscribe("wallpaper") defer lm.Unsubscribe("wallpaper")
for { for {
select { select {
@@ -133,7 +148,7 @@ func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
m.TriggerUpdate() m.TriggerUpdate()
} }
} }
}() })
} }
func (m *Manager) schedulerLoop() { func (m *Manager) schedulerLoop() {
@@ -154,11 +169,26 @@ func (m *Manager) schedulerLoop() {
delete(schedules, key) delete(schedules, key)
} }
} }
firesDirty := false
for key, cfg := range active { for key, cfg := range active {
s, ok := schedules[key] s, ok := schedules[key]
switch { switch {
case !ok: case !ok:
schedules[key] = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)} s = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)}
schedules[key] = s
if cfg.Mode == "time" {
last := m.lastFires[key]
prev, valid := prevDailyTime(now, cfg.Time)
switch {
case last.IsZero():
// First enable (or no history): seed instead of firing.
m.lastFires[key] = now
firesDirty = true
case valid && last.Before(prev):
// Scheduled time passed while dms wasn't running.
s.nextFire = now.Add(catchUpDelay)
}
}
case s.cfg != cfg || resets[key]: case s.cfg != cfg || resets[key]:
s.cfg = cfg s.cfg = cfg
s.nextFire = computeNext(now, cfg) s.nextFire = computeNext(now, cfg)
@@ -171,9 +201,22 @@ func (m *Manager) schedulerLoop() {
if !s.nextFire.After(now) { if !s.nextFire.After(now) {
dueKeys = append(dueKeys, key) dueKeys = append(dueKeys, key)
s.nextFire = computeNext(now, s.cfg) s.nextFire = computeNext(now, s.cfg)
if s.cfg.Mode == "time" {
m.lastFires[key] = now
firesDirty = true
}
} }
} }
if firesDirty {
for key := range m.lastFires {
if _, ok := schedules[key]; !ok {
delete(m.lastFires, key)
}
}
m.persistFires(m.lastFires)
}
next, hasNext := soonest(schedules) next, hasNext := soonest(schedules)
if len(dueKeys) == 0 { if len(dueKeys) == 0 {
m.setState(config, next, seq, "") m.setState(config, next, seq, "")
@@ -185,10 +228,7 @@ func (m *Manager) schedulerLoop() {
waitDur := 24 * time.Hour waitDur := 24 * time.Hour
if hasNext { if hasNext {
waitDur = time.Until(next) waitDur = max(time.Until(next), time.Second)
if waitDur < time.Second {
waitDur = time.Second
}
} }
if timer != nil { if timer != nil {
@@ -262,10 +302,7 @@ func computeNext(now time.Time, cfg ScheduleConfig) time.Time {
case "time": case "time":
return nextDailyTime(now, cfg.Time) return nextDailyTime(now, cfg.Time)
default: default:
sec := cfg.IntervalSec sec := max(cfg.IntervalSec, 1)
if sec < 1 {
sec = 1
}
return now.Add(time.Duration(sec) * time.Second) return now.Add(time.Duration(sec) * time.Second)
} }
} }
@@ -282,6 +319,18 @@ func nextDailyTime(now time.Time, hhmm string) time.Time {
return next return next
} }
func prevDailyTime(now time.Time, hhmm string) (time.Time, bool) {
hour, minute, ok := parseHHMM(hhmm)
if !ok {
return time.Time{}, false
}
prev := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if prev.After(now) {
prev = prev.Add(-24 * time.Hour)
}
return prev, true
}
func parseHHMM(hhmm string) (int, int, bool) { func parseHHMM(hhmm string) (int, int, bool) {
parts := strings.Split(hhmm, ":") parts := strings.Split(hhmm, ":")
if len(parts) != 2 { if len(parts) != 2 {
+74 -1
View File
@@ -83,8 +83,81 @@ func TestActiveSchedules(t *testing.T) {
} }
} }
func TestPrevDailyTime(t *testing.T) {
now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
earlier, ok := prevDailyTime(now, "06:00")
if !ok || !earlier.Equal(time.Date(2026, 6, 30, 6, 0, 0, 0, time.UTC)) {
t.Errorf("prev (today) = %v, %v", earlier, ok)
}
later, ok := prevDailyTime(now, "18:00")
if !ok || !later.Equal(time.Date(2026, 6, 29, 18, 0, 0, 0, time.UTC)) {
t.Errorf("prev (yesterday) = %v, %v", later, ok)
}
if _, ok := prevDailyTime(now, "bad"); ok {
t.Error("prev (invalid) should not be ok")
}
}
func TestTimeScheduleCatchUpAfterRestart(t *testing.T) {
origDelay := catchUpDelay
catchUpDelay = 100 * time.Millisecond
defer func() { catchUpDelay = origDelay }()
now := time.Now()
hhmm := now.Add(-2 * time.Hour).Format("15:04")
seed := map[string]time.Time{"": now.Add(-26 * time.Hour)}
m := newManager(seed, func(map[string]time.Time) {})
defer m.Close()
sub := m.Subscribe("test")
defer m.Unsubscribe("test")
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}})
deadline := time.After(3 * time.Second)
for {
select {
case state := <-sub:
if state.CycleSeq > 0 && state.Target == "" {
return
}
case <-deadline:
t.Fatal("missed time schedule did not catch up within 3s")
}
}
}
func TestTimeScheduleNoFireOnFirstEnable(t *testing.T) {
now := time.Now()
hhmm := now.Add(-2 * time.Hour).Format("15:04")
m := newManager(map[string]time.Time{}, func(map[string]time.Time) {})
defer m.Close()
sub := m.Subscribe("test")
defer m.Unsubscribe("test")
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}})
deadline := time.After(1500 * time.Millisecond)
for {
select {
case state := <-sub:
if state.CycleSeq > 0 {
t.Fatal("first enable must not fire immediately")
}
case <-deadline:
return
}
}
}
func TestSchedulerEmitsCycle(t *testing.T) { func TestSchedulerEmitsCycle(t *testing.T) {
m := NewManager() m := newManager(map[string]time.Time{}, func(map[string]time.Time) {})
defer m.Close() defer m.Close()
sub := m.Subscribe("test") sub := m.Subscribe("test")
+45
View File
@@ -0,0 +1,45 @@
package wallpaper
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type persistentState struct {
LastFires map[string]time.Time `json:"lastFires"`
}
func stateFilePath() string {
return filepath.Join(utils.XDGCacheHome(), "dms", "wallpaper-schedule.json")
}
func loadLastFires() map[string]time.Time {
data, err := os.ReadFile(stateFilePath())
if err != nil {
return map[string]time.Time{}
}
var state persistentState
if err := json.Unmarshal(data, &state); err != nil || state.LastFires == nil {
return map[string]time.Time{}
}
return state.LastFires
}
func saveLastFires(fires map[string]time.Time) {
path := stateFilePath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
data, err := json.Marshal(persistentState{LastFires: fires})
if err != nil {
return
}
if err := os.WriteFile(path, data, 0o644); err != nil {
log.Warnf("wallpaper: failed to persist schedule state: %v", err)
}
}
+10 -4
View File
@@ -108,10 +108,7 @@ func (sc *SharedContext) eventDispatcher() {
if r := recover(); r != nil { if r := recover(); r != nil {
err := fmt.Errorf("FATAL: Wayland event dispatcher panic: %v", r) err := fmt.Errorf("FATAL: Wayland event dispatcher panic: %v", r)
log.Error(err) log.Error(err)
select { sc.signalFatal(err)
case sc.fatalError <- err:
default:
}
} }
}() }()
@@ -141,6 +138,7 @@ func (sc *SharedContext) eventDispatcher() {
continue continue
case err != nil: case err != nil:
log.Errorf("Poll error: %v", err) log.Errorf("Poll error: %v", err)
sc.signalFatal(fmt.Errorf("wayland poll error: %w", err))
return return
} }
@@ -161,6 +159,7 @@ func (sc *SharedContext) eventDispatcher() {
if consecutiveErrors >= maxConsecutiveErrors { if consecutiveErrors >= maxConsecutiveErrors {
log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors) log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors)
sc.signalFatal(fmt.Errorf("wayland connection unrecoverable after %d dispatch failures: %w", maxConsecutiveErrors, err))
return return
} }
@@ -172,6 +171,13 @@ func (sc *SharedContext) eventDispatcher() {
} }
} }
func (sc *SharedContext) signalFatal(err error) {
select {
case sc.fatalError <- err:
default:
}
}
func (sc *SharedContext) drainCmdQueue() { func (sc *SharedContext) drainCmdQueue() {
for { for {
select { select {
+72 -1
View File
@@ -1,7 +1,10 @@
package utils package utils
import ( import (
"os"
"os/exec" "os/exec"
"path/filepath"
"strings"
) )
type AppChecker interface { type AppChecker interface {
@@ -31,7 +34,20 @@ func (DefaultAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
func CommandExists(cmd string) bool { func CommandExists(cmd string) bool {
_, err := exec.LookPath(cmd) _, err := exec.LookPath(cmd)
return err == nil if err == nil {
return true
}
if strings.ContainsRune(cmd, os.PathSeparator) {
return false
}
for _, dir := range userBinDirs() {
path := filepath.Join(dir, cmd)
info, statErr := os.Stat(path)
if statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
return true
}
}
return false
} }
func AnyCommandExists(cmds ...string) bool { func AnyCommandExists(cmds ...string) bool {
@@ -42,3 +58,58 @@ func AnyCommandExists(cmds ...string) bool {
} }
return false return false
} }
func EnvWithUserBinPath(env []string) []string {
if env == nil {
env = os.Environ()
}
out := append([]string(nil), env...)
pathIndex := -1
pathValue := ""
for i, entry := range out {
if strings.HasPrefix(entry, "PATH=") {
pathIndex = i
pathValue = strings.TrimPrefix(entry, "PATH=")
break
}
}
parts := filepath.SplitList(pathValue)
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
if part != "" {
seen[part] = struct{}{}
}
}
prepend := make([]string, 0, 2)
for _, dir := range userBinDirs() {
if dir == "" {
continue
}
if _, ok := seen[dir]; ok {
continue
}
prepend = append(prepend, dir)
seen[dir] = struct{}{}
}
parts = append(prepend, parts...)
newPath := "PATH=" + strings.Join(parts, string(os.PathListSeparator))
if pathIndex >= 0 {
out[pathIndex] = newPath
} else {
out = append(out, newPath)
}
return out
}
func userBinDirs() []string {
dirs := []string{}
if home, err := os.UserHomeDir(); err == nil && home != "" {
dirs = append(dirs, filepath.Join(home, ".local", "bin"))
}
dirs = append(dirs, "/usr/local/bin")
return dirs
}
+55
View File
@@ -0,0 +1,55 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommandExistsFallsBackToLocalBin(t *testing.T) {
home := t.TempDir()
binDir := filepath.Join(home, ".local", "bin")
require.NoError(t, os.MkdirAll(binDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(binDir, "pywalfox"), []byte("#!/bin/sh\n"), 0o755))
t.Setenv("HOME", home)
t.Setenv("PATH", t.TempDir())
assert.True(t, CommandExists("pywalfox"))
}
func TestCommandExistsIgnoresNonExecutableLocalBinFile(t *testing.T) {
home := t.TempDir()
binDir := filepath.Join(home, ".local", "bin")
require.NoError(t, os.MkdirAll(binDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(binDir, "pywalfox"), []byte("not executable"), 0o644))
t.Setenv("HOME", home)
t.Setenv("PATH", t.TempDir())
assert.False(t, CommandExists("pywalfox"))
}
func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
env := EnvWithUserBinPath([]string{"PATH=/usr/bin", "OTHER=value"})
var pathValue string
for _, entry := range env {
if strings.HasPrefix(entry, "PATH=") {
pathValue = strings.TrimPrefix(entry, "PATH=")
break
}
}
parts := filepath.SplitList(pathValue)
require.NotEmpty(t, parts)
assert.Equal(t, filepath.Join(home, ".local", "bin"), parts[0])
assert.Contains(t, parts, "/usr/local/bin")
assert.Contains(t, env, "OTHER=value")
}
+1
View File
@@ -74,6 +74,7 @@ override_dh_auto_install:
install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \ install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \
install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \ install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \
install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \ install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \
install -Dm644 assets/com.danklinux.dms.notepad.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop; \
install -Dm644 assets/danklogo.svg debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \ install -Dm644 assets/danklogo.svg debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \
else \ else \
echo "ERROR: quickshell directory not found!" && \ echo "ERROR: quickshell directory not found!" && \
+1
View File
@@ -67,6 +67,7 @@ override_dh_auto_install:
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.notepad.desktop debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/danklogo.svg debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/danklogo.svg debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \
else \ else \
echo "ERROR: DankMaterialShell-$(UPSTREAM_VERSION) directory not found!" && \ echo "ERROR: DankMaterialShell-$(UPSTREAM_VERSION) directory not found!" && \
+2
View File
@@ -122,6 +122,7 @@ install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.servic
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
# Install shell files to shared data location # Install shell files to shared data location
@@ -146,6 +147,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg %{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
%files -n dms-cli %files -n dms-cli
+2
View File
@@ -90,6 +90,7 @@ install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_us
install -Dm644 %{_builddir}/dms-qml/assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 %{_builddir}/dms-qml/assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 %{_builddir}/dms-qml/assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 %{_builddir}/dms-qml/assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
install -dm755 %{buildroot}%{_datadir}/quickshell/dms install -dm755 %{buildroot}%{_datadir}/quickshell/dms
@@ -113,6 +114,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg %{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
%files -n dms-cli %files -n dms-cli
+1 -2
View File
@@ -18,8 +18,7 @@ in
] ]
++ lib.optional cfg.enableDynamicTheming pkgs.matugen ++ lib.optional cfg.enableDynamicTheming pkgs.matugen
++ lib.optional cfg.enableAudioWavelength pkgs.cava ++ lib.optional cfg.enableAudioWavelength pkgs.cava
++ lib.optional cfg.enableCalendarEvents pkgs.khal ++ lib.optional cfg.enableCalendarEvents pkgs.khal;
++ lib.optional cfg.enableClipboardPaste pkgs.wtype;
plugins = lib.mapAttrs (name: plugin: { plugins = lib.mapAttrs (name: plugin: {
source = plugin.src; source = plugin.src;
+49 -1
View File
@@ -10,6 +10,8 @@ let
inherit (lib) types; inherit (lib) types;
cfg = config.programs.dank-material-shell.greeter; cfg = config.programs.dank-material-shell.greeter;
cfgDms = config.programs.dank-material-shell; cfgDms = config.programs.dank-material-shell;
cfgAutoLogin = config.services.displayManager.autoLogin;
sessionData = config.services.displayManager.sessionData;
inherit (config.services.greetd.settings.default_session) user; inherit (config.services.greetd.settings.default_session) user;
@@ -26,6 +28,7 @@ let
cfg.quickshell.package cfg.quickshell.package
compositorPackage compositorPackage
pkgs.glib # provides gdbus, used by the fprintd hardware probe in GreeterContent.qml pkgs.glib # provides gdbus, used by the fprintd hardware probe in GreeterContent.qml
pkgs.jq # reads the user's cursor theme from settings.json in dms-greeter
] ]
} }
${ ${
@@ -48,6 +51,38 @@ let
} ${lib.optionalString cfg.logs.save "> ${cfg.logs.path} 2>&1"} } ${lib.optionalString cfg.logs.save "> ${cfg.logs.path} 2>&1"}
''; '';
autoLoginCommand =
pkgs.runCommand "dms-greeter-autologin-command"
{
nativeBuildInputs = [
pkgs.gnugrep
pkgs.coreutils
];
}
''
set -euo pipefail
session="${sessionData.autologinSession}"
desktops="${sessionData.desktops}"
for sessionFile in \
"$desktops/share/wayland-sessions/$session.desktop" \
"$desktops/share/xsessions/$session.desktop"
do
if [ -f "$sessionFile" ]; then
command="$(grep -m1 '^Exec=' "$sessionFile" | cut -d= -f2- || true)"
if [ -n "$command" ]; then
printf '%s\n' "$command" > "$out"
exit 0
fi
fi
done
echo "dms-greeter autologin: could not resolve Exec for session '$session'" >&2
exit 1
'';
jq = lib.getExe pkgs.jq; jq = lib.getExe pkgs.jq;
in in
{ {
@@ -155,6 +190,13 @@ in
dmsgreeter: user set for greetd default_session ${user} does not exist. Please create it before referencing it. dmsgreeter: user set for greetd default_session ${user} does not exist. Please create it before referencing it.
''; '';
} }
{
assertion = cfgAutoLogin.enable -> sessionData.autologinSession != null;
message = ''
dms-greeter auto-login requires services.displayManager.defaultSession to be set,
or at least one session in services.displayManager.sessionPackages.
'';
}
]; ];
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS. # DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
# Declare security.pam.services.dankshell only if you want to override that runtime fallback. # Declare security.pam.services.dankshell only if you want to override that runtime fallback.
@@ -165,7 +207,13 @@ in
# }; # };
services.greetd = { services.greetd = {
enable = lib.mkDefault true; enable = lib.mkDefault true;
settings.default_session.command = lib.mkDefault (lib.getExe greeterScript); settings = {
default_session.command = lib.mkDefault (lib.getExe greeterScript);
initial_session = lib.mkIf (cfgAutoLogin.enable && (cfgAutoLogin.user != null)) {
inherit (cfgAutoLogin) user;
command = ''${lib.getExe pkgs.bash} -lc "${pkgs.systemd}/bin/systemd-cat $(<${autoLoginCommand})"'';
};
};
}; };
fonts.packages = with pkgs; [ fonts.packages = with pkgs; [
fira-code fira-code
+1 -1
View File
@@ -76,7 +76,7 @@ in
enableClipboardPaste = lib.mkOption { enableClipboardPaste = lib.mkOption {
type = types.bool; type = types.bool;
default = true; default = true;
description = "Adds needed dependencies for directly pasting items from the clipboard history."; description = "Deprecated: paste is built into dms; no extra dependencies needed. Kept as a no-op for compatibility.";
}; };
quickshell = { quickshell = {
+11
View File
@@ -16,8 +16,14 @@ pkgs.testers.runNixOSTest {
isSystemUser = true; isSystemUser = true;
group = "greeter"; group = "greeter";
}; };
users.users.alice.isNormalUser = true;
services.greetd.settings.default_session.user = "greeter"; services.greetd.settings.default_session.user = "greeter";
services.displayManager.autoLogin = {
enable = true;
user = "alice";
};
services.displayManager.defaultSession = "niri";
programs.niri.enable = true; programs.niri.enable = true;
@@ -46,6 +52,11 @@ pkgs.testers.runNixOSTest {
greetd_config_path = config_match.group(1) greetd_config_path = config_match.group(1)
greetd_config = machine.succeed(f"cat {greetd_config_path}") greetd_config = machine.succeed(f"cat {greetd_config_path}")
t.assertIn("dms-greeter", greetd_config) t.assertIn("dms-greeter", greetd_config)
t.assertIn("[initial_session]", greetd_config)
initial_session = greetd_config.split("[initial_session]", 1)[1]
t.assertIn('user = "alice"', initial_session)
t.assertIn("systemd-cat", initial_session)
script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config) script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config)
if script_match is None: if script_match is None:
+3 -1
View File
@@ -25,7 +25,7 @@ Recommends: danksearch
Recommends: matugen Recommends: matugen
Recommends: quickshell-git Recommends: quickshell-git
Recommends: NetworkManager Recommends: NetworkManager
Recommends: qt6-qtmultimedia Recommends: qt6-multimedia-imports
Suggests: cups-pk-helper Suggests: cups-pk-helper
Suggests: qt6ct Suggests: qt6ct
@@ -117,6 +117,7 @@ install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.servic
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
install -dm755 %{buildroot}%{_datadir}/quickshell/dms install -dm755 %{buildroot}%{_datadir}/quickshell/dms
@@ -153,6 +154,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%dir %{_datadir}/icons/hicolor %dir %{_datadir}/icons/hicolor
%dir %{_datadir}/icons/hicolor/scalable %dir %{_datadir}/icons/hicolor/scalable
%dir %{_datadir}/icons/hicolor/scalable/apps %dir %{_datadir}/icons/hicolor/scalable/apps
+3 -1
View File
@@ -26,7 +26,7 @@ Recommends: cava
Recommends: danksearch Recommends: danksearch
Recommends: matugen Recommends: matugen
Recommends: NetworkManager Recommends: NetworkManager
Recommends: qt6-qtmultimedia Recommends: qt6-multimedia-imports
Suggests: cups-pk-helper Suggests: cups-pk-helper
Suggests: qt6ct Suggests: qt6ct
@@ -66,6 +66,7 @@ install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.servic
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
install -dm755 %{buildroot}%{_datadir}/quickshell/dms install -dm755 %{buildroot}%{_datadir}/quickshell/dms
@@ -100,6 +101,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%dir %{_datadir}/icons/hicolor %dir %{_datadir}/icons/hicolor
%dir %{_datadir}/icons/hicolor/scalable %dir %{_datadir}/icons/hicolor/scalable
%dir %{_datadir}/icons/hicolor/scalable/apps %dir %{_datadir}/icons/hicolor/scalable/apps
+2
View File
@@ -75,6 +75,8 @@ override_dh_auto_install:
debian/dms-git/usr/share/applications/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop
install -Dm644 dms-git-repo/assets/com.danklinux.dms.desktop \ install -Dm644 dms-git-repo/assets/com.danklinux.dms.desktop \
debian/dms-git/usr/share/applications/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop
install -Dm644 dms-git-repo/assets/com.danklinux.dms.notepad.desktop \
debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop
install -Dm644 dms-git-repo/assets/danklogo.svg \ install -Dm644 dms-git-repo/assets/danklogo.svg \
debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg
+2
View File
@@ -56,6 +56,8 @@ override_dh_auto_install:
debian/dms/usr/share/applications/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.desktop \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.desktop \
debian/dms/usr/share/applications/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.notepad.desktop \
debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/danklogo.svg \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/danklogo.svg \
debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg
-1
View File
@@ -61,7 +61,6 @@ install whichever features you want:
| `cava` | audio visualiser widget | | `cava` | audio visualiser widget |
| `qt6-multimedia` | system sound feedback | | `qt6-multimedia` | system sound feedback |
| `qt6ct` | Qt app theming | | `qt6ct` | Qt app theming |
| `wtype` | virtual keyboard input |
| `power-profiles-daemon` | power profile control | | `power-profiles-daemon` | power profile control |
| `cups-pk-helper` | printer management | | `cups-pk-helper` | printer management |
| `NetworkManager` | network control | | `NetworkManager` | network control |
+1
View File
@@ -43,6 +43,7 @@ post_install() {
# Desktop entry + icon # Desktop entry + icon
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.notepad.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps
# Shell completions (generated by the built binary; skip when cross-building) # Shell completions (generated by the built binary; skip when cross-building)
+1
View File
@@ -33,6 +33,7 @@ post_install() {
# Desktop entry + icon # Desktop entry + icon
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.notepad.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps
# Shell completions (generated by the built binary; skip when cross-building) # Shell completions (generated by the built binary; skip when cross-building)
+4
View File
@@ -684,6 +684,10 @@ Notepad/scratchpad modal control for quick note-taking.
- `open` - Show notepad modal - `open` - Show notepad modal
- `close` - Hide notepad modal - `close` - Hide notepad modal
- `toggle` - Toggle notepad modal visibility - `toggle` - Toggle notepad modal visibility
- `openFile <path>` - Show notepad and load a file into a tab, reusing an existing tab if that file is already open
- Parameters: `path` - Absolute path to a file. Empty path behaves like `open`.
- Returns: Success/failure message
- This is what `com.danklinux.dms.notepad.desktop`'s `Exec=dms ipc call notepad openFile %f` uses so Notepad shows up in "Open With" pickers for text files.
- `expand` - Expand the active notepad width and open it if hidden - `expand` - Expand the active notepad width and open it if hidden
- `collapse` - Collapse the active notepad width without changing visibility - `collapse` - Collapse the active notepad width without changing visibility
- `toggleExpand` - Toggle the active notepad width between collapsed and expanded - `toggleExpand` - Toggle the active notepad width between collapsed and expanded
+5
View File
@@ -133,11 +133,16 @@
$out/share/applications/dms-open.desktop $out/share/applications/dms-open.desktop
install -D ${rootSrc}/assets/com.danklinux.dms.desktop \ install -D ${rootSrc}/assets/com.danklinux.dms.desktop \
$out/share/applications/com.danklinux.dms.desktop $out/share/applications/com.danklinux.dms.desktop
install -D ${rootSrc}/assets/com.danklinux.dms.notepad.desktop \
$out/share/applications/com.danklinux.dms.notepad.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \ install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg $out/share/hicolor/scalable/apps/danklogo.svg
# Snapshot pre-wrap Qt paths for launched apps to restore.
wrapProgram $out/bin/dms \ wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \ --add-flags "-c $out/share/quickshell/dms" \
--run 'export DMS_ORIG_NIXPKGS_QT6_QML_IMPORT_PATH="''${NIXPKGS_QT6_QML_IMPORT_PATH:-}"' \
--run 'export DMS_ORIG_QT_PLUGIN_PATH="''${QT_PLUGIN_PATH:-}"' \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \ --prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}" --prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
+81 -29
View File
@@ -68,27 +68,6 @@ const KEY_MAP = {
16777429: "XF86Explorer", 16777429: "XF86Explorer",
16777442: "XF86Launch0", 16777442: "XF86Launch0",
16777443: "XF86Launch1", 16777443: "XF86Launch1",
33: "1",
64: "2",
35: "3",
36: "4",
37: "5",
94: "6",
38: "7",
42: "8",
40: "9",
41: "0",
60: "Comma",
62: "Period",
63: "Slash",
58: "Semicolon",
34: "Apostrophe",
123: "BracketLeft",
125: "BracketRight",
124: "Backslash",
95: "Minus",
43: "Equal",
126: "grave",
196: "Adiaeresis", 196: "Adiaeresis",
214: "Odiaeresis", 214: "Odiaeresis",
220: "Udiaeresis", 220: "Udiaeresis",
@@ -126,6 +105,56 @@ const KEY_MAP = {
161: "exclamdown" 161: "exclamdown"
}; };
// Preserve unshifted symbols from the active layout
const SYMBOL_KEYSYM = {
33: "exclam",
34: "quotedbl",
35: "numbersign",
36: "dollar",
37: "percent",
38: "ampersand",
40: "parenleft",
41: "parenright",
42: "asterisk",
43: "plus",
58: "colon",
60: "less",
62: "greater",
63: "question",
64: "at",
94: "asciicircum",
95: "underscore",
123: "braceleft",
124: "bar",
125: "braceright",
126: "asciitilde"
};
// Preserve the existing shifted-US physical-key mapping
const SHIFTED_US_FALLBACK = {
33: "1",
34: "Apostrophe",
35: "3",
36: "4",
37: "5",
38: "7",
40: "9",
41: "0",
42: "8",
43: "Equal",
58: "Semicolon",
60: "Comma",
62: "Period",
63: "Slash",
64: "2",
94: "6",
95: "Minus",
123: "BracketLeft",
124: "Backslash",
125: "BracketRight",
126: "grave"
};
// Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and // Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and
// the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and // the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and
// the other compositors bind against the xkb KP_* keysym names, so we must emit // the other compositors bind against the xkb KP_* keysym names, so we must emit
@@ -153,13 +182,17 @@ const KP_MAP = {
46: "KP_Decimal" 46: "KP_Decimal"
}; };
function xkbKeyFromQtKey(qk, isKeypad) { function xkbKeyFromQtKey(qk, isKeypad, hasShift) {
if (isKeypad) { if (isKeypad) {
if (qk >= 48 && qk <= 57) if (qk >= 48 && qk <= 57)
return "KP_" + (qk - 48); return "KP_" + (qk - 48);
if (KP_MAP[qk]) if (KP_MAP[qk])
return KP_MAP[qk]; return KP_MAP[qk];
} }
if (!hasShift && SYMBOL_KEYSYM[qk])
return SYMBOL_KEYSYM[qk];
if (hasShift && SHIFTED_US_FALLBACK[qk])
return SHIFTED_US_FALLBACK[qk];
if (qk >= 65 && qk <= 90) if (qk >= 65 && qk <= 90)
return String.fromCharCode(qk); return String.fromCharCode(qk);
if (qk >= 97 && qk <= 122) if (qk >= 97 && qk <= 122)
@@ -192,23 +225,42 @@ function formatToken(mods, key) {
return (mods.length ? mods.join("+") + "+" : "") + key; return (mods.length ? mods.join("+") + "+" : "") + key;
} }
function normalizeKeyCombo(keyCombo) { function canonicalModifier(modifier) {
if (!keyCombo) var normalized = (modifier || "").toLowerCase();
return ""; if (normalized === "control")
return keyCombo.toLowerCase().replace(/\bmod\b/g, "super").replace(/\bsuper\b/g, "super"); return "ctrl";
if (normalized === "win")
return "super";
return normalized;
} }
function getConflictingBinds(keyCombo, currentAction, allBinds) { function withSymbolicMod(mods, modKey) {
var configuredMod = canonicalModifier(modKey);
if (!configuredMod)
return mods;
return mods.map(function (modifier) {
return canonicalModifier(modifier) === configuredMod ? "Mod" : modifier;
});
}
function normalizeKeyCombo(keyCombo, modKey) {
if (!keyCombo)
return "";
var configuredMod = canonicalModifier(modKey) || "super";
return keyCombo.toLowerCase().replace(/\bmod\b/g, configuredMod).replace(/\bcontrol\b/g, "ctrl").replace(/\bwin\b/g, "super");
}
function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
if (!keyCombo) if (!keyCombo)
return []; return [];
var conflicts = []; var conflicts = [];
var normalizedKey = normalizeKeyCombo(keyCombo); var normalizedKey = normalizeKeyCombo(keyCombo, modKey);
for (var i = 0; i < allBinds.length; i++) { for (var i = 0; i < allBinds.length; i++) {
var bind = allBinds[i]; var bind = allBinds[i];
if (bind.action === currentAction) if (bind.action === currentAction)
continue; continue;
for (var k = 0; k < bind.keys.length; k++) { for (var k = 0; k < bind.keys.length; k++) {
if (normalizeKeyCombo(bind.keys[k].key) === normalizedKey) { if (normalizeKeyCombo(bind.keys[k].key, modKey) === normalizedKey) {
conflicts.push({ conflicts.push({
action: bind.action, action: bind.action,
desc: bind.desc || bind.action desc: bind.desc || bind.action
+107
View File
@@ -0,0 +1,107 @@
.pragma library
// English language name -> ISO 639-1 code, for abbreviating xkb layout names.
const LANG_CODES = {
"afrikaans": "af",
"albanian": "sq",
"amharic": "am",
"arabic": "ar",
"armenian": "hy",
"azerbaijani": "az",
"basque": "eu",
"belarusian": "be",
"bengali": "bn",
"bosnian": "bs",
"bulgarian": "bg",
"burmese": "my",
"catalan": "ca",
"chinese": "zh",
"croatian": "hr",
"czech": "cs",
"danish": "da",
"dutch": "nl",
"english": "en",
"esperanto": "eo",
"estonian": "et",
"filipino": "fil",
"finnish": "fi",
"french": "fr",
"galician": "gl",
"georgian": "ka",
"german": "de",
"greek": "el",
"gujarati": "gu",
"hausa": "ha",
"hebrew": "he",
"hindi": "hi",
"hungarian": "hu",
"icelandic": "is",
"igbo": "ig",
"indonesian": "id",
"irish": "ga",
"italian": "it",
"japanese": "ja",
"javanese": "jv",
"kannada": "kn",
"kazakh": "kk",
"khmer": "km",
"korean": "ko",
"kurdish": "ku",
"kyrgyz": "ky",
"lao": "lo",
"latvian": "lv",
"lithuanian": "lt",
"luxembourgish": "lb",
"macedonian": "mk",
"malay": "ms",
"malayalam": "ml",
"maltese": "mt",
"maori": "mi",
"marathi": "mr",
"mongolian": "mn",
"nepali": "ne",
"norwegian": "no",
"pashto": "ps",
"persian": "fa",
"iranian": "fa",
"farsi": "fa",
"polish": "pl",
"portuguese": "pt",
"punjabi": "pa",
"romanian": "ro",
"russian": "ru",
"serbian": "sr",
"sindhi": "sd",
"sinhala": "si",
"slovak": "sk",
"slovenian": "sl",
"somali": "so",
"spanish": "es",
"swahili": "sw",
"swedish": "sv",
"tajik": "tg",
"tamil": "ta",
"tatar": "tt",
"telugu": "te",
"thai": "th",
"tibetan": "bo",
"turkish": "tr",
"turkmen": "tk",
"ukrainian": "uk",
"urdu": "ur",
"uyghur": "ug",
"uzbek": "uz",
"vietnamese": "vi",
"welsh": "cy",
"yiddish": "yi",
"yoruba": "yo",
"zulu": "zu"
};
function layoutCode(layoutName) {
if (!layoutName)
return "";
const lang = layoutName.split(" ")[0].toLowerCase();
const code = LANG_CODES[lang] || lang.substring(0, 2);
return code.toUpperCase();
}
+29 -7
View File
@@ -79,12 +79,6 @@ Singleton {
function _closePopout(popout) { function _closePopout(popout) {
try { try {
if (popout?.hoverDismissEnabled) {
if (typeof popout.closeFromHoverDismiss === "function") {
popout.closeFromHoverDismiss();
return;
}
}
if (popout.hoverDismissEnabled !== undefined) if (popout.hoverDismissEnabled !== undefined)
popout.hoverDismissEnabled = false; popout.hoverDismissEnabled = false;
switch (true) { switch (true) {
@@ -104,6 +98,20 @@ Singleton {
} }
} }
function _dismissPopoutFromHover(popout) {
try {
if (!popout || popout.hoverDismissEnabled !== true)
return;
if (typeof popout.closeFromHoverDismiss === "function") {
popout.closeFromHoverDismiss();
return;
}
_closePopout(popout);
} catch (e) {
return;
}
}
function _isStale(popout) { function _isStale(popout) {
try { try {
if (!popout || !("shouldBeVisible" in popout)) if (!popout || !("shouldBeVisible" in popout))
@@ -174,6 +182,19 @@ Singleton {
_closePopout(popout); _closePopout(popout);
} }
function dismissHoverPopoutForScreen(screen) {
if (!screen)
return;
const screenName = screen.name;
const popout = currentPopoutsByScreen[screenName];
if (!popout || _isStale(popout)) {
currentPopoutsByScreen[screenName] = null;
currentPopoutTriggers[screenName] = null;
return;
}
_dismissPopoutFromHover(popout);
}
function cancelHoverDismiss(screen) { function cancelHoverDismiss(screen) {
const popout = getActivePopout(screen); const popout = getActivePopout(screen);
if (popout?.cancelHoverDismiss) if (popout?.cancelHoverDismiss)
@@ -191,7 +212,8 @@ Singleton {
const p = getActivePopout(screen); const p = getActivePopout(screen);
if (!p || !_isPopoutPresented(p)) if (!p || !_isPopoutPresented(p))
return false; return false;
return p.hoverDismissEnabled === false || p.hoverDismissSuspended === true; const dismissSuspended = p.effectiveHoverDismissSuspended ?? p.hoverDismissSuspended;
return p.hoverDismissEnabled === false || dismissSuspended === true;
} }
function isCurrentPopout(popout, screenName) { function isCurrentPopout(popout, screenName) {
+54 -4
View File
@@ -15,7 +15,7 @@ Singleton {
id: root id: root
readonly property var log: Log.scoped("SettingsData") readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 11 readonly property int settingsConfigVersion: 12
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -177,10 +177,12 @@ Singleton {
property int niriLayoutRadiusOverride: -1 property int niriLayoutRadiusOverride: -1
property int niriLayoutBorderSize: -1 property int niriLayoutBorderSize: -1
property int hyprlandLayoutGapsOverride: -1 property int hyprlandLayoutGapsOverride: -1
property int hyprlandLayoutGapsOutOverride: -1
property int hyprlandLayoutRadiusOverride: -1 property int hyprlandLayoutRadiusOverride: -1
property int hyprlandLayoutBorderSize: -1 property int hyprlandLayoutBorderSize: -1
property bool hyprlandResizeOnBorder: false property bool hyprlandResizeOnBorder: false
property int mangoLayoutGapsOverride: -1 property int mangoLayoutGapsOverride: -1
property int mangoLayoutGapsOutOverride: -1
property int mangoLayoutRadiusOverride: -1 property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1 property int mangoLayoutBorderSize: -1
property bool mangoTrackpadNaturalScrolling: true property bool mangoTrackpadNaturalScrolling: true
@@ -460,6 +462,7 @@ Singleton {
property bool clockCompactMode: false property bool clockCompactMode: false
property int focusedWindowSize: 1 property int focusedWindowSize: 1
property bool focusedWindowCompactMode: false property bool focusedWindowCompactMode: false
property bool focusedWindowShowIcon: false
property bool runningAppsCompactMode: true property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 0 property int barMaxVisibleApps: 0
property int barMaxVisibleRunningApps: 0 property int barMaxVisibleRunningApps: 0
@@ -494,6 +497,8 @@ Singleton {
property string greeterLockDateFormat: "" property string greeterLockDateFormat: ""
property string greeterFontFamily: "" property string greeterFontFamily: ""
property string greeterWallpaperFillMode: "" property string greeterWallpaperFillMode: ""
property bool greeterSyncPending: false
property var greeterSyncBaseline: ({})
property int mediaSize: 1 property int mediaSize: 1
property string appLauncherViewMode: "list" property string appLauncherViewMode: "list"
@@ -748,12 +753,16 @@ Singleton {
property bool batteryNotifyCritical: true property bool batteryNotifyCritical: true
property int batteryLowThreshold: 20 property int batteryLowThreshold: 20
property bool batteryNotifyLow: false property bool batteryNotifyLow: false
property int batteryNotificationType: 0 property int batteryChargeLimitNotificationType: 0
property int batteryLowNotificationType: 0
property int batteryCriticalNotificationType: 1
property bool batteryAutoPowerSaver: false property bool batteryAutoPowerSaver: false
property bool showBatteryPercent: true property bool showBatteryPercent: true
property bool showBatteryPercentOnlyOnBattery: false property bool showBatteryPercentOnlyOnBattery: false
property bool showBatteryTime: false property bool showBatteryTime: false
property bool showBatteryTimeOnlyOnBattery: false property bool showBatteryTimeOnlyOnBattery: false
property bool batteryPillStyle: false
property bool batteryPillPercentSign: false
property bool lockBeforeSuspend: false property bool lockBeforeSuspend: false
property bool loginctlLockIntegration: true property bool loginctlLockIntegration: true
property bool fadeToLockEnabled: true property bool fadeToLockEnabled: true
@@ -885,12 +894,14 @@ Singleton {
readonly property bool greeterU2fReady: Processes.greeterU2fReady readonly property bool greeterU2fReady: Processes.greeterU2fReady
readonly property string greeterU2fReason: Processes.greeterU2fReason readonly property string greeterU2fReason: Processes.greeterU2fReason
readonly property string greeterU2fSource: Processes.greeterU2fSource readonly property string greeterU2fSource: Processes.greeterU2fSource
property string lockScreenActiveMonitor: "all"
property string lockScreenInactiveColor: "#000000" property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0 property int lockScreenNotificationMode: 0
property bool lockScreenVideoEnabled: false property bool lockScreenVideoEnabled: false
property string lockScreenVideoPath: "" property string lockScreenVideoPath: ""
property bool lockScreenVideoCycling: false property bool lockScreenVideoCycling: false
property string lockScreenWallpaperPath: ""
property string lockScreenWallpaperFillMode: ""
property string lockScreenFontFamily: ""
property bool hideBrightnessSlider: false property bool hideBrightnessSlider: false
property int notificationTimeoutLow: 5000 property int notificationTimeoutLow: 5000
@@ -1677,6 +1688,32 @@ Singleton {
}); });
} }
function markGreeterSyncPending(who, key, oldValue) {
if (isGreeterMode)
return;
if (!(key in greeterSyncBaseline)) {
var baseline = greeterSyncBaseline;
baseline[key] = oldValue;
greeterSyncBaseline = baseline;
}
greeterSyncPending = true;
}
function clearGreeterSyncPending() {
greeterSyncBaseline = {};
greeterSyncPending = false;
saveSettings();
}
function revertGreeterSyncPending() {
for (var key in greeterSyncBaseline) {
root[key] = greeterSyncBaseline[key];
}
greeterSyncBaseline = {};
greeterSyncPending = false;
saveSettings();
}
readonly property var _hooks: ({ readonly property var _hooks: ({
"applyStoredTheme": applyStoredTheme, "applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes, "regenSystemThemes": regenSystemThemes,
@@ -1685,7 +1722,8 @@ Singleton {
"updateBarConfigs": updateBarConfigs, "updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor, "updateCompositorCursor": updateCompositorCursor,
"scheduleAuthApply": scheduleAuthApply, "scheduleAuthApply": scheduleAuthApply,
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync "scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync,
"markGreeterSyncPending": markGreeterSyncPending
}) })
function set(key, value) { function set(key, value) {
@@ -1711,6 +1749,18 @@ Singleton {
} }
} }
if (obj?.lockScreenActiveMonitor !== undefined) {
var oldVal = obj.lockScreenActiveMonitor;
if (oldVal && oldVal !== "all") {
if (!obj.screenPreferences)
obj.screenPreferences = {};
if (obj.screenPreferences.lockScreen === undefined) {
obj.screenPreferences.lockScreen = [oldVal];
}
}
delete obj.lockScreenActiveMonitor;
}
Store.parse(root, obj); Store.parse(root, obj);
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected") if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
+14 -7
View File
@@ -142,6 +142,8 @@ Singleton {
Component.onCompleted: { Component.onCompleted: {
Quickshell.execDetached(["mkdir", "-p", stateDir]); Quickshell.execDetached(["mkdir", "-p", stateDir]);
if (typeof SessionData === "undefined" || !SessionData.isGreeterMode)
Quickshell.execDetached([shellDir + "/scripts/gtk.sh", configDir, "", shellDir, "assets-only"]);
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => { Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen; matugenAvailable = (code === 0) && !envDisableMatugen;
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
@@ -1801,8 +1803,11 @@ Singleton {
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default"; const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
if (currentTheme === dynamic) { if (currentTheme === dynamic) {
if (!rawWallpaperPath) if (!rawWallpaperPath) {
log.warn("Auto theme has no wallpaper - skipping matugen, syncing portal mode only");
PortalService.setLightMode(isLight);
return; return;
}
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot"; const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
const kind = rawWallpaperPath.startsWith("#") ? "hex" : "image"; const kind = rawWallpaperPath.startsWith("#") ? "hex" : "image";
setDesiredTheme(kind, rawWallpaperPath, isLight, iconTheme, selectedMatugenType, null); setDesiredTheme(kind, rawWallpaperPath, isLight, iconTheme, selectedMatugenType, null);
@@ -1827,19 +1832,21 @@ Singleton {
const storedLight = isGreeterMode ? (GreetdSettings.registryThemeVariants[themeId]?.light || lightDefaults) : (typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, lightDefaults, "light") : lightDefaults); const storedLight = isGreeterMode ? (GreetdSettings.registryThemeVariants[themeId]?.light || lightDefaults) : (typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, lightDefaults, "light") : lightDefaults);
const darkFlavorId = storedDark.flavor || darkDefaults.flavor || ""; const darkFlavorId = storedDark.flavor || darkDefaults.flavor || "";
const lightFlavorId = storedLight.flavor || lightDefaults.flavor || ""; const lightFlavorId = storedLight.flavor || lightDefaults.flavor || "";
const accentId = storedDark.accent || darkDefaults.accent || ""; const darkAccentId = storedDark.accent || darkDefaults.accent || "";
const lightAccentId = storedLight.accent || lightDefaults.accent || "";
const darkFlavor = findVariant(customThemeRawData.variants.flavors, darkFlavorId); const darkFlavor = findVariant(customThemeRawData.variants.flavors, darkFlavorId);
const lightFlavor = findVariant(customThemeRawData.variants.flavors, lightFlavorId); const lightFlavor = findVariant(customThemeRawData.variants.flavors, lightFlavorId);
const accent = findAccent(customThemeRawData.variants.accents, accentId); const darkAccent = findAccent(customThemeRawData.variants.accents, darkAccentId);
const lightAccent = findAccent(customThemeRawData.variants.accents, lightAccentId);
if (darkFlavor) { if (darkFlavor) {
darkTheme = mergeColors(darkTheme, darkFlavor.dark || {}); darkTheme = mergeColors(darkTheme, darkFlavor.dark || {});
if (accent) if (darkAccent)
darkTheme = mergeColors(darkTheme, accent[darkFlavor.id] || {}); darkTheme = mergeColors(darkTheme, darkAccent[darkFlavor.id] || {});
} }
if (lightFlavor) { if (lightFlavor) {
lightTheme = mergeColors(lightTheme, lightFlavor.light || {}); lightTheme = mergeColors(lightTheme, lightFlavor.light || {});
if (accent) if (lightAccent)
lightTheme = mergeColors(lightTheme, accent[lightFlavor.id] || {}); lightTheme = mergeColors(lightTheme, lightAccent[lightFlavor.id] || {});
} }
} else if (customThemeRawData.variants.options) { } else if (customThemeRawData.variants.options) {
const isGreeterMode = typeof SessionData !== "undefined" && SessionData.isGreeterMode; const isGreeterMode = typeof SessionData !== "undefined" && SessionData.isGreeterMode;
+6
View File
@@ -32,6 +32,8 @@ Singleton {
showBatteryPercentOnlyOnBattery: false, showBatteryPercentOnlyOnBattery: false,
showBatteryTime: false, showBatteryTime: false,
showBatteryTimeOnlyOnBattery: false, showBatteryTimeOnlyOnBattery: false,
batteryPillStyle: false,
batteryPillPercentSign: false,
showPrinterIcon: false, showPrinterIcon: false,
showScreenSharingIcon: true showScreenSharingIcon: true
}; };
@@ -95,6 +97,10 @@ Singleton {
item.showBatteryTime = order[i].showBatteryTime; item.showBatteryTime = order[i].showBatteryTime;
if (isObj && order[i].showBatteryTimeOnlyOnBattery !== undefined) if (isObj && order[i].showBatteryTimeOnlyOnBattery !== undefined)
item.showBatteryTimeOnlyOnBattery = order[i].showBatteryTimeOnlyOnBattery; item.showBatteryTimeOnlyOnBattery = order[i].showBatteryTimeOnlyOnBattery;
if (isObj && order[i].batteryPillStyle !== undefined)
item.batteryPillStyle = order[i].batteryPillStyle;
if (isObj && order[i].batteryPillPercentSign !== undefined)
item.batteryPillPercentSign = order[i].batteryPillPercentSign;
if (isObj && order[i].showPrinterIcon !== undefined) if (isObj && order[i].showPrinterIcon !== undefined)
item.showPrinterIcon = order[i].showPrinterIcon; item.showPrinterIcon = order[i].showPrinterIcon;
if (isObj && order[i].showScreenSharingIcon !== undefined) if (isObj && order[i].showScreenSharingIcon !== undefined)
+8 -27
View File
@@ -332,7 +332,6 @@ Singleton {
property bool greeterAutoLoginSyncRerunRequested: false property bool greeterAutoLoginSyncRerunRequested: false
property string greeterAutoLoginSyncStdout: "" property string greeterAutoLoginSyncStdout: ""
property string greeterAutoLoginSyncStderr: "" property string greeterAutoLoginSyncStderr: ""
property string greeterAutoLoginSyncTerminalFallbackStderr: ""
function scheduleGreeterAutoLoginSync() { function scheduleGreeterAutoLoginSync() {
if (!settingsRoot || settingsRoot.isGreeterMode) if (!settingsRoot || settingsRoot.isGreeterMode)
@@ -355,15 +354,16 @@ Singleton {
greeterAutoLoginSyncRerunRequested = false; greeterAutoLoginSyncRerunRequested = false;
greeterAutoLoginSyncStdout = ""; greeterAutoLoginSyncStdout = "";
greeterAutoLoginSyncStderr = ""; greeterAutoLoginSyncStderr = "";
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncRunning = true; greeterAutoLoginSyncRunning = true;
greeterAutoLoginSyncSudoProbeProcess.running = true; greeterAutoLoginSyncSudoProbeProcess.running = true;
} }
function launchGreeterAutoLoginSyncTerminalFallback(details) { function deferGreeterAutoLoginSyncToPill(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"); ToastService.dismissCategory("greeter-autologin-sync");
greeterAutoLoginSyncTerminalFallbackStderr = ""; if (settingsRoot)
greeterAutoLoginSyncTerminalFallbackProcess.running = true; settingsRoot.set("greeterSyncPending", true);
ToastService.showWarning(I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin", "greeter-autologin-sync");
finishGreeterAutoLoginSync();
} }
function greeterAutoLoginSyncSuccessToast(details) { function greeterAutoLoginSyncSuccessToast(details) {
@@ -559,7 +559,7 @@ Singleton {
details = out; details = out;
if (err !== "") if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err; details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.launchGreeterAutoLoginSyncTerminalFallback(details); root.deferGreeterAutoLoginSyncToPill(details);
} }
} }
@@ -575,26 +575,7 @@ Singleton {
return; return;
} }
root.launchGreeterAutoLoginSyncTerminalFallback(); root.deferGreeterAutoLoginSyncToPill("");
}
}
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();
} }
} }
+24 -12
View File
@@ -29,10 +29,12 @@ var SPEC = {
niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutGapsOutOverride: { 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" }, hyprlandResizeOnBorder: { def: false, onChange: "updateCompositorLayout" },
mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutGapsOutOverride: { 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" }, mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
@@ -100,6 +102,8 @@ var SPEC = {
showBatteryPercentOnlyOnBattery: { def: false }, showBatteryPercentOnlyOnBattery: { def: false },
showBatteryTime: { def: false }, showBatteryTime: { def: false },
showBatteryTimeOnlyOnBattery: { def: false }, showBatteryTimeOnlyOnBattery: { def: false },
batteryPillStyle: { def: false },
batteryPillPercentSign: { def: false },
showControlCenterButton: { def: true }, showControlCenterButton: { def: true },
showCapsLockIndicator: { def: true }, showCapsLockIndicator: { def: true },
@@ -188,6 +192,7 @@ var SPEC = {
clockCompactMode: { def: false }, clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false }, focusedWindowCompactMode: { def: false },
focusedWindowSize: { def: 1 }, focusedWindowSize: { def: 1 },
focusedWindowShowIcon: { def: false },
runningAppsCompactMode: { def: true }, runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 }, barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 }, barMaxVisibleRunningApps: { def: 0 },
@@ -218,18 +223,20 @@ var SPEC = {
centeringMode: { def: "index" }, centeringMode: { def: "index" },
clockDateFormat: { def: "" }, clockDateFormat: { def: "" },
lockDateFormat: { def: "" }, lockDateFormat: { def: "" },
greeterRememberLastSession: { def: true }, greeterRememberLastSession: { def: true, onChange: "markGreeterSyncPending" },
greeterRememberLastUser: { def: true }, greeterRememberLastUser: { def: true, onChange: "markGreeterSyncPending" },
greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" }, 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: "", onChange: "markGreeterSyncPending" },
greeterUse24HourClock: { def: true }, greeterUse24HourClock: { def: true, onChange: "markGreeterSyncPending" },
greeterShowSeconds: { def: false }, greeterShowSeconds: { def: false, onChange: "markGreeterSyncPending" },
greeterPadHours12Hour: { def: false }, greeterPadHours12Hour: { def: false, onChange: "markGreeterSyncPending" },
greeterLockDateFormat: { def: "" }, greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
greeterFontFamily: { def: "" }, greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
greeterWallpaperFillMode: { def: "" }, greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
greeterSyncPending: { def: false },
greeterSyncBaseline: { def: {} },
mediaSize: { def: 1 }, mediaSize: { def: 1 },
appLauncherViewMode: { def: "list" }, appLauncherViewMode: { def: "list" },
@@ -335,7 +342,9 @@ var SPEC = {
batteryNotifyCritical: { def: true }, batteryNotifyCritical: { def: true },
batteryLowThreshold: { def: 20 }, batteryLowThreshold: { def: 20 },
batteryNotifyLow: { def: false }, batteryNotifyLow: { def: false },
batteryNotificationType: { def: 0 }, batteryChargeLimitNotificationType: { def: 0 },
batteryLowNotificationType: { def: 0 },
batteryCriticalNotificationType: { def: 1 },
batteryAutoPowerSaver: { def: false }, batteryAutoPowerSaver: { def: false },
lockBeforeSuspend: { def: false }, lockBeforeSuspend: { def: false },
loginctlLockIntegration: { def: true }, loginctlLockIntegration: { def: true },
@@ -447,12 +456,14 @@ var SPEC = {
maxFprintTries: { def: 15 }, maxFprintTries: { def: 15 },
enableU2f: { def: false, onChange: "scheduleAuthApply" }, enableU2f: { def: false, onChange: "scheduleAuthApply" },
u2fMode: { def: "or" }, u2fMode: { def: "or" },
lockScreenActiveMonitor: { def: "all" },
lockScreenInactiveColor: { def: "#000000" }, lockScreenInactiveColor: { def: "#000000" },
lockScreenNotificationMode: { def: 0 }, lockScreenNotificationMode: { def: 0 },
lockScreenVideoEnabled: { def: false }, lockScreenVideoEnabled: { def: false },
lockScreenVideoPath: { def: "" }, lockScreenVideoPath: { def: "" },
lockScreenVideoCycling: { def: false }, lockScreenVideoCycling: { def: false },
lockScreenWallpaperPath: { def: "" },
lockScreenWallpaperFillMode: { def: "" },
lockScreenFontFamily: { def: "" },
hideBrightnessSlider: { def: false }, hideBrightnessSlider: { def: false },
notificationTimeoutLow: { def: 5000 }, notificationTimeoutLow: { def: 5000 },
@@ -659,10 +670,11 @@ 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; if (value === undefined || value === null) value = SPEC[key].def;
var oldValue = root[key];
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]) {
hooks[hookName](root); hooks[hookName](root, key, oldValue);
} }
saveFn(); saveFn();
} }
@@ -252,6 +252,17 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 11; settings.configVersion = 11;
} }
if (currentVersion < 12) {
console.info("Migrating settings from version", currentVersion, "to version 12");
if (settings.batteryNotificationType !== undefined) {
settings.batteryChargeLimitNotificationType = settings.batteryNotificationType;
settings.batteryLowNotificationType = settings.batteryNotificationType;
settings.batteryCriticalNotificationType = settings.batteryNotificationType;
delete settings.batteryNotificationType;
}
settings.configVersion = 12;
}
return settings; return settings;
} }
+16
View File
@@ -385,6 +385,22 @@ Item {
return "NOTEPAD_OPEN_FAILED"; return "NOTEPAD_OPEN_FAILED";
} }
function openFile(path: string): string {
if (!path)
return open();
if (SettingsData.notepadDefaultMode === "popout") {
PopoutService.openNotepadPopoutWithFile(path);
return "NOTEPAD_OPEN_FILE_SUCCESS";
}
var instance = getActiveNotepadInstance();
if (instance) {
instance.show();
instance.loadedItem?.openExternalFile(path);
return "NOTEPAD_OPEN_FILE_SUCCESS";
}
return "NOTEPAD_OPEN_FILE_FAILED";
}
function close(): string { function close(): string {
if (SettingsData.notepadDefaultMode === "popout") { if (SettingsData.notepadDefaultMode === "popout") {
PopoutService.notepadPopout?.hide(); PopoutService.notepadPopout?.hide();
+28
View File
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell
import qs.Common import qs.Common
import qs.Modals.Common import qs.Modals.Common
import qs.Widgets import qs.Widgets
@@ -23,6 +24,7 @@ DankModal {
property var rememberMimeTypes: [] property var rememberMimeTypes: []
property bool rememberChoice: false property bool rememberChoice: false
property var mimeMatchedAppIds: [] property var mimeMatchedAppIds: []
property var mimeMatchedRawIds: []
signal applicationSelected(var app, string targetData) signal applicationSelected(var app, string targetData)
@@ -61,6 +63,7 @@ DankModal {
function fetchMimeMatches() { function fetchMimeMatches() {
mimeMatchedAppIds = []; mimeMatchedAppIds = [];
mimeMatchedRawIds = [];
const queriedMime = mimeType; const queriedMime = mimeType;
if (queriedMime.length === 0) if (queriedMime.length === 0)
return; return;
@@ -74,6 +77,7 @@ DankModal {
return; return;
} }
const ids = (response.result && response.result.desktopIds) || []; const ids = (response.result && response.result.desktopIds) || [];
mimeMatchedRawIds = ids;
mimeMatchedAppIds = ids.map(_normAppId); mimeMatchedAppIds = ids.map(_normAppId);
updateApplicationList(); updateApplicationList();
}); });
@@ -93,11 +97,13 @@ DankModal {
const hasMimeMatches = mimeMatchedAppIds.length > 0; const hasMimeMatches = mimeMatchedAppIds.length > 0;
const lowerQuery = searchQuery.toLowerCase(); const lowerQuery = searchQuery.toLowerCase();
let filteredApps = []; let filteredApps = [];
const listedIds = new Set();
for (const app of apps) { for (const app of apps) {
if (!app) if (!app)
continue; continue;
const appId = _normAppId(app.id || app.execString || app.exec || ""); const appId = _normAppId(app.id || app.execString || app.exec || "");
listedIds.add(appId);
const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId); const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId);
const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType); const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType);
const mimeMatch = mimeIdMatch || mimeFieldMatch; const mimeMatch = mimeIdMatch || mimeFieldMatch;
@@ -135,6 +141,28 @@ DankModal {
}); });
} }
// NoDisplay entries are excluded from DesktopEntries.applications but
// remain valid mime handlers; resolve them by id so they stay pickable
for (const rawId of mimeMatchedRawIds) {
const normId = _normAppId(rawId);
if (normId === "dms-open" || listedIds.has(normId))
continue;
const entry = DesktopEntries.byId(rawId) || DesktopEntries.heuristicLookup(rawId);
if (!entry)
continue;
const name = entry.name || "";
if (searchQuery !== "" && !name.toLowerCase().includes(lowerQuery))
continue;
filteredApps.push({
name: name,
icon: entry.icon || "application-x-executable",
exec: entry.execString || "",
startupClass: entry.startupClass || "",
appData: entry,
mimeMatch: true
});
}
filteredApps.sort((a, b) => { filteredApps.sort((a, b) => {
if (a.mimeMatch !== b.mimeMatch) { if (a.mimeMatch !== b.mimeMatch) {
return a.mimeMatch ? -1 : 1; return a.mimeMatch ? -1 : 1;
@@ -8,6 +8,7 @@ Item {
id: clipboardContent id: clipboardContent
required property var modal required property var modal
property var transientSurfaceTracker: null
property alias searchField: searchField property alias searchField: searchField
property alias clipboardListView: clipboardListView property alias clipboardListView: clipboardListView
@@ -66,7 +67,7 @@ Item {
contextMenu.hide(); contextMenu.hide();
} }
readonly property bool contextMenuActive: contextMenu.openState readonly property bool contextMenuActive: contextMenu.renderActive
anchors.fill: parent anchors.fill: parent
@@ -74,6 +75,7 @@ Item {
id: contextMenu id: contextMenu
modal: clipboardContent.modal modal: clipboardContent.modal
parentHandler: clipboardContent parentHandler: clipboardContent
transientSurfaceTracker: clipboardContent.transientSurfaceTracker
} }
Column { Column {
@@ -394,7 +396,7 @@ Item {
} }
sourceComponent: ClipboardKeyboardHints { sourceComponent: ClipboardKeyboardHints {
wtypeAvailable: modal.wtypeAvailable pasteAvailable: modal.pasteAvailable
enterToPaste: SettingsData.clipboardEnterToPaste enterToPaste: SettingsData.clipboardEnterToPaste
} }
} }
@@ -23,6 +23,8 @@ Item {
property real anchorY: 0 property real anchorY: 0
property bool openState: false property bool openState: false
property bool renderActive: false property bool renderActive: false
property var transientSurfaceTracker: null
readonly property alias contextWindow: menuWindow
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
@@ -90,6 +92,18 @@ Item {
readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight) readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight)
readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5 readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5
onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow)
Component.onDestruction: transientSurfaceTracker?.unregister(root)
Connections {
target: root.transientSurfaceTracker
ignoreUnknownSignals: true
function onCloseRequested() {
root.hide();
}
}
TextMetrics { TextMetrics {
id: menuTextMetrics id: menuTextMetrics
text: root.longestMenuText text: root.longestMenuText
@@ -258,8 +272,8 @@ Item {
height: root.effectiveMenuHeight height: root.effectiveMenuHeight
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium border.color: BlurService.borderColor
border.width: BlurService.enabled ? BlurService.borderWidth : 1 border.width: BlurService.borderWidth
opacity: root.openState ? 1 : 0 opacity: root.openState ? 1 : 0
Behavior on opacity { Behavior on opacity {
@@ -496,7 +496,7 @@ Item {
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2 implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0) color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
opacity: modal.wtypeAvailable ? 1 : 0.5 opacity: modal.pasteAvailable ? 1 : 0.5
Row { Row {
id: saveMenuPasteRow id: saveMenuPasteRow
@@ -522,7 +522,7 @@ Item {
id: saveMenuPasteArea id: saveMenuPasteArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
enabled: modal.wtypeAvailable enabled: modal.pasteAvailable
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: { onClicked: {
saveMenu.close(); saveMenu.close();
@@ -49,6 +49,15 @@ Rectangle {
cornerRadius: root.radius cornerRadius: root.radius
} }
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: mouse => {
const scenePos = mapToItem(null, mouse.x, mouse.y);
contextMenuRequested(scenePos.x, scenePos.y);
}
}
Rectangle { Rectangle {
id: indexBadge id: indexBadge
anchors.left: parent.left anchors.left: parent.left
@@ -234,13 +243,4 @@ Rectangle {
} }
} }
} }
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: mouse => {
const scenePos = mapToItem(null, mouse.x, mouse.y);
contextMenuRequested(scenePos.x, scenePos.y);
}
}
} }
@@ -9,6 +9,7 @@ FocusScope {
property var clearConfirmDialog: null property var clearConfirmDialog: null
property var surfaceHost: null property var surfaceHost: null
property var transientSurfaceTracker: null
property string activeTab: "recents" property string activeTab: "recents"
property bool showKeyboardHints: false property bool showKeyboardHints: false
@@ -20,7 +21,7 @@ FocusScope {
property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all" property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all"
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable readonly property bool pasteAvailable: ClipboardService.pasteAvailable
readonly property int totalCount: ClipboardService.totalCount readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries readonly property var pinnedEntries: ClipboardService.pinnedEntries
@@ -198,6 +199,7 @@ FocusScope {
id: historyContent id: historyContent
anchors.fill: parent anchors.fill: parent
modal: root modal: root
transientSurfaceTracker: root.transientSurfaceTracker
} }
} }
@@ -130,6 +130,7 @@ DankModal {
content: Component { content: Component {
ClipboardHistoryContent { ClipboardHistoryContent {
surfaceHost: clipboardHistoryModal surfaceHost: clipboardHistoryModal
transientSurfaceTracker: clipboardHistoryModal.transientSurfaceTracker
clearConfirmDialog: clearConfirmDialog clearConfirmDialog: clearConfirmDialog
onCloseRequested: clipboardHistoryModal.hide() onCloseRequested: clipboardHistoryModal.hide()
onInstantCloseRequested: clipboardHistoryModal.instantHide() onInstantCloseRequested: clipboardHistoryModal.instantHide()
@@ -141,6 +141,7 @@ DankPopout {
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
surfaceHost: root surfaceHost: root
transientSurfaceTracker: root.transientSurfaceTracker
clearConfirmDialog: clearConfirmDialog clearConfirmDialog: clearConfirmDialog
onCloseRequested: root.hide() onCloseRequested: root.hide()
onInstantCloseRequested: root.hide() onInstantCloseRequested: root.hide()
@@ -5,10 +5,10 @@ import qs.Widgets
Rectangle { Rectangle {
id: keyboardHints id: keyboardHints
property bool wtypeAvailable: false property bool pasteAvailable: false
property bool enterToPaste: false property bool enterToPaste: false
readonly property string hintsText: { readonly property string hintsText: {
if (!wtypeAvailable) if (!pasteAvailable)
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 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"); 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");
} }
+11 -1
View File
@@ -2,6 +2,7 @@ import QtQuick
import Quickshell.Hyprland import Quickshell.Hyprland
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets
Item { Item {
id: root id: root
@@ -38,6 +39,7 @@ Item {
property bool keepContentLoaded: false property bool keepContentLoaded: false
property bool keepPopoutsOpen: false property bool keepPopoutsOpen: false
property var customKeyboardFocus: null property var customKeyboardFocus: null
readonly property alias transientSurfaceTracker: _transientSurfaceTracker
property bool useOverlayLayer: false property bool useOverlayLayer: false
signal opened signal opened
@@ -47,6 +49,10 @@ Item {
readonly property var contentLoader: impl.item ? impl.item.contentLoader : null readonly property var contentLoader: impl.item ? impl.item.contentLoader : null
readonly property alias modalFocusScope: _modalFocusScope readonly property alias modalFocusScope: _modalFocusScope
TransientSurfaceTracker {
id: _transientSurfaceTracker
}
FocusScope { FocusScope {
id: _modalFocusScope id: _modalFocusScope
objectName: "modalFocusScope" objectName: "modalFocusScope"
@@ -56,7 +62,7 @@ Item {
// Hyprland OnDemand grab delivers keyboard focus to the modal content surface. // Hyprland OnDemand grab delivers keyboard focus to the modal content surface.
HyprlandFocusGrab { HyprlandFocusGrab {
windows: root.contentWindow ? [root.contentWindow] : [] windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? [])
active: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus) active: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus)
property var restoreToplevel: null property var restoreToplevel: null
@@ -79,11 +85,13 @@ Item {
} }
function close() { function close() {
transientSurfaceTracker?.closeAll?.();
if (impl.item) if (impl.item)
impl.item.close(); impl.item.close();
} }
function instantClose() { function instantClose() {
transientSurfaceTracker?.closeAll?.();
if (impl.item && typeof impl.item.instantClose === "function") if (impl.item && typeof impl.item.instantClose === "function")
impl.item.instantClose(); impl.item.instantClose();
} }
@@ -175,6 +183,8 @@ Item {
Connections { Connections {
target: root target: root
function onShouldBeVisibleChanged() { function onShouldBeVisibleChanged() {
if (!root.shouldBeVisible)
root.transientSurfaceTracker?.closeAll?.();
if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible) if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible)
impl.item.shouldBeVisible = root.shouldBeVisible; impl.item.shouldBeVisible = root.shouldBeVisible;
} }
+10 -30
View File
@@ -162,37 +162,16 @@ Item {
_publishModalChromeState(); _publishModalChromeState();
} }
property bool _animSyncQueued: false
property bool _bodySyncQueued: false
function _queueFullSync() { function _queueFullSync() {
_fullSyncPending = true; _fullSyncPending = true;
if (!_syncTimer.running) if (!_syncTimer.running)
_syncTimer.restart(); _syncTimer.restart();
} }
function _queueAnimSync() {
_animSyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueBodySync() {
_bodySyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _flushSync() { function _flushSync() {
const fullDirty = _fullSyncPending; if (!_fullSyncPending)
const animDirty = _animSyncQueued; return;
const bodyDirty = _bodySyncQueued;
_fullSyncPending = false; _fullSyncPending = false;
_animSyncQueued = false; _syncModalChromeState();
_bodySyncQueued = false;
if (fullDirty)
_syncModalChromeState();
if (animDirty)
_syncModalAnim();
if (bodyDirty)
_syncModalBody();
} }
function _syncModalAnim() { function _syncModalAnim() {
@@ -216,10 +195,11 @@ Item {
onFrameOwnsConnectedChromeChanged: _syncModalChromeState() onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
onResolvedConnectedBarSideChanged: _queueFullSync() onResolvedConnectedBarSideChanged: _queueFullSync()
onShouldBeVisibleChanged: _queueFullSync() onShouldBeVisibleChanged: _queueFullSync()
onAlignedXChanged: _queueBodySync() // Low resource scalar writes: publish synchronously to stay in the same frame
onAlignedYChanged: _queueBodySync() onAlignedXChanged: _syncModalBody()
onAlignedWidthChanged: _queueBodySync() onAlignedYChanged: _syncModalBody()
onAlignedHeightChanged: _queueBodySync() onAlignedWidthChanged: _syncModalBody()
onAlignedHeightChanged: _syncModalBody()
Connections { Connections {
target: contentWindow target: contentWindow
@@ -613,9 +593,9 @@ Item {
readonly property real scaleValue: computedScaleCollapsed + (1.0 - computedScaleCollapsed) * morph.openProgress readonly property real scaleValue: computedScaleCollapsed + (1.0 - computedScaleCollapsed) * morph.openProgress
onAnimXChanged: if (root.frameOwnsConnectedChrome) onAnimXChanged: if (root.frameOwnsConnectedChrome)
root._queueAnimSync() root._syncModalAnim()
onAnimYChanged: if (root.frameOwnsConnectedChrome) onAnimYChanged: if (root.frameOwnsConnectedChrome)
root._queueAnimSync() root._syncModalAnim()
Item { Item {
id: contentContainer id: contentContainer
@@ -244,11 +244,11 @@ Item {
WindowBlur { WindowBlur {
targetWindow: contentWindow targetWindow: contentWindow
readonly property real s: Math.min(1, modalContainer.scaleValue) readonly property real s: Math.min(1, modalContainer.scaleValue)
readonly property real op: Math.max(0, Math.min(1, (morph.openProgress - 0.06) * 2)) // Blur tracks the surface's scaled rect, matching the connected backend.
blurX: modalContainer.x + modalContainer.width * (1 - s * op) * 0.5 + Theme.snap(modalContainer.animX, root.dpr) blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
blurY: modalContainer.y + modalContainer.height * (1 - s * op) * 0.5 + Theme.snap(modalContainer.animY, root.dpr) blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
blurWidth: root.shouldBeVisible ? modalContainer.width * s * op : 0 blurWidth: root.shouldBeVisible ? modalContainer.width * s : 0
blurHeight: root.shouldBeVisible ? modalContainer.height * s * op : 0 blurHeight: root.shouldBeVisible ? modalContainer.height * s : 0
blurRadius: root.cornerRadius blurRadius: root.cornerRadius
} }
@@ -145,12 +145,6 @@ Item {
} }
} }
Process {
id: wtypeProcess
command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"]
running: false
}
Process { Process {
id: copyProcess id: copyProcess
running: false running: false
@@ -161,7 +155,7 @@ Item {
id: pasteTimer id: pasteTimer
interval: 200 interval: 200
repeat: false repeat: false
onTriggered: wtypeProcess.running = true onTriggered: ClipboardService.sendPasteKeystroke()
} }
function pasteSelected() { function pasteSelected() {
@@ -179,10 +173,6 @@ Item {
} }
return; return;
} }
if (!SessionService.wtypeAvailable) {
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
return;
}
const pluginId = selectedItem.pluginId; const pluginId = selectedItem.pluginId;
if (!pluginId) if (!pluginId)
@@ -35,6 +35,10 @@ Item {
property real _frozenMotionX: 0 property real _frozenMotionX: 0
property real _frozenMotionY: 0 property real _frozenMotionY: 0
TransientSurfaceTracker {
id: transientSurfaces
}
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property var effectiveScreen: contentWindow.screen readonly property var effectiveScreen: contentWindow.screen
readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenWidth: effectiveScreen?.width ?? 1920
@@ -499,10 +503,18 @@ Item {
} }
// Handles hover dismissal grace periods for edge-hover sessions w/cursor // Handles hover dismissal grace periods for edge-hover sessions w/cursor
readonly property bool _edgeRetractEnabled: (modalHandle && modalHandle.edgeHoverManaged === true) && spotlightOpen && !isClosing readonly property bool _edgeRetractEnabled: (modalHandle && modalHandle.edgeHoverManaged === true) && spotlightOpen && !isClosing && !transientSurfaces.active
property bool _edgeBodyHover: false property bool _edgeBodyHover: false
property bool _edgeArmed: false property bool _edgeArmed: false
on_EdgeRetractEnabledChanged: {
if (!_edgeRetractEnabled) {
_edgeRetractGrace.stop();
} else if (_edgeArmed && !_edgeBodyHover) {
_edgeRetractGrace.restart();
}
}
Timer { Timer {
id: _edgeRetractGrace id: _edgeRetractGrace
interval: 150 interval: 150
@@ -534,9 +546,7 @@ Item {
HyprlandFocusGrab { HyprlandFocusGrab {
id: focusGrab id: focusGrab
readonly property var contextMenuWindow: root.spotlightContent?.activeContextMenu?.contextWindow ?? null windows: [contentWindow].concat(transientSurfaces.focusWindows)
readonly property bool contextMenuActive: root.spotlightContent?.activeContextMenu?.renderActive ?? false
windows: contextMenuActive && contextMenuWindow ? [contentWindow, contextMenuWindow] : [contentWindow]
active: root.useHyprlandFocusGrab && root.spotlightOpen active: root.useHyprlandFocusGrab && root.spotlightOpen
onCleared: { onCleared: {
@@ -869,6 +879,7 @@ Item {
sourceComponent: LauncherContent { sourceComponent: LauncherContent {
focus: true focus: true
parentModal: root parentModal: root
transientSurfaceTracker: transientSurfaces
} }
onLoaded: { onLoaded: {
@@ -26,6 +26,10 @@ Item {
property string _pendingMode: "" property string _pendingMode: ""
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
TransientSurfaceTracker {
id: transientSurfaces
}
readonly property var effectiveScreen: launcherWindow.screen readonly property var effectiveScreen: launcherWindow.screen
readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenWidth: effectiveScreen?.width ?? 1920
readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real screenHeight: effectiveScreen?.height ?? 1080
@@ -228,9 +232,7 @@ Item {
HyprlandFocusGrab { HyprlandFocusGrab {
id: focusGrab id: focusGrab
readonly property var contextMenuWindow: root.spotlightContent?.activeContextMenu?.contextWindow ?? null windows: [launcherWindow].concat(transientSurfaces.focusWindows)
readonly property bool contextMenuActive: root.spotlightContent?.activeContextMenu?.renderActive ?? false
windows: contextMenuActive && contextMenuWindow ? [launcherWindow, contextMenuWindow] : [launcherWindow]
active: root.useHyprlandFocusGrab && root.keyboardActive active: root.useHyprlandFocusGrab && root.keyboardActive
onCleared: { onCleared: {
if (spotlightOpen) if (spotlightOpen)
@@ -482,6 +484,7 @@ Item {
sourceComponent: SpotlightLauncherContent { sourceComponent: SpotlightLauncherContent {
focus: true focus: true
parentModal: root parentModal: root
transientSurfaceTracker: transientSurfaces
} }
onLoaded: { onLoaded: {
@@ -27,6 +27,10 @@ Item {
readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
TransientSurfaceTracker {
id: transientSurfaces
}
readonly property var effectiveScreen: launcherWindow.screen readonly property var effectiveScreen: launcherWindow.screen
readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenWidth: effectiveScreen?.width ?? 1920
readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real screenHeight: effectiveScreen?.height ?? 1080
@@ -260,9 +264,7 @@ Item {
HyprlandFocusGrab { HyprlandFocusGrab {
id: focusGrab id: focusGrab
readonly property var contextMenuWindow: root.spotlightContent?.activeContextMenu?.contextWindow ?? null windows: [launcherWindow].concat(transientSurfaces.focusWindows)
readonly property bool contextMenuActive: root.spotlightContent?.activeContextMenu?.renderActive ?? false
windows: contextMenuActive && contextMenuWindow ? [launcherWindow, contextMenuWindow] : [launcherWindow]
active: root.useHyprlandFocusGrab && root.keyboardActive active: root.useHyprlandFocusGrab && root.keyboardActive
onCleared: { onCleared: {
@@ -364,11 +366,11 @@ Item {
WindowBlur { WindowBlur {
targetWindow: launcherWindow targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.publishedScale) readonly property real s: Math.min(1, modalContainer.publishedScale)
readonly property real op: Math.max(0, Math.min(1, (modalContainer.publishedOpacity - 0.06) * 2)) // Blur tracks the surface's scaled rect
blurX: modalContainer.x + modalContainer.width * (1 - s * op) * 0.5 blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5
blurY: modalContainer.y + modalContainer.height * (1 - s * op) * 0.5 blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5
blurWidth: contentVisible ? modalContainer.width * s * op : 0 blurWidth: contentVisible ? modalContainer.width * s : 0
blurHeight: contentVisible ? modalContainer.height * s * op : 0 blurHeight: contentVisible ? modalContainer.height * s : 0
blurRadius: root.cornerRadius blurRadius: root.cornerRadius
} }
@@ -530,6 +532,7 @@ Item {
sourceComponent: LauncherContent { sourceComponent: LauncherContent {
focus: true focus: true
parentModal: root parentModal: root
transientSurfaceTracker: transientSurfaces
} }
onLoaded: { onLoaded: {
@@ -18,6 +18,7 @@ FocusScope {
property alias resultsList: resultsList property alias resultsList: resultsList
property alias actionPanel: actionPanel property alias actionPanel: actionPanel
readonly property alias activeContextMenu: contextMenu readonly property alias activeContextMenu: contextMenu
property var transientSurfaceTracker: null
property bool editMode: false property bool editMode: false
property var editingApp: null property var editingApp: null
@@ -43,7 +44,7 @@ FocusScope {
} }
function closeTransientUi() { function closeTransientUi() {
contextMenu.hide(); transientSurfaceTracker?.closeAll?.();
actionPanel.hide(); actionPanel.hide();
root.enabled = true; root.enabled = true;
} }
@@ -123,6 +124,7 @@ FocusScope {
controller: root.controller controller: root.controller
searchField: root.searchField searchField: root.searchField
parentHandler: root parentHandler: root
transientSurfaceTracker: root.transientSurfaceTracker
onEditAppRequested: app => { onEditAppRequested: app => {
root.openEditMode(app); root.openEditMode(app);
@@ -779,6 +781,7 @@ FocusScope {
anchors.fill: parent anchors.fill: parent
controller: root.controller controller: root.controller
leadingSectionHeaderAtBottom: contentHolder.inverted leadingSectionHeaderAtBottom: contentHolder.inverted
transientSurfaceTracker: root.transientSurfaceTracker
onItemRightClicked: (index, item, sceneX, sceneY) => { onItemRightClicked: (index, item, sceneX, sceneY) => {
if (item && contextMenu.hasContextMenuActions(item)) { if (item && contextMenu.hasContextMenuActions(item)) {
@@ -25,6 +25,7 @@ Item {
property real anchorY: 0 property real anchorY: 0
property bool openState: false property bool openState: false
property bool renderActive: false property bool renderActive: false
property var transientSurfaceTracker: null
readonly property alias contextWindow: menuWindow readonly property alias contextWindow: menuWindow
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
@@ -49,6 +50,18 @@ Item {
signal hideRequested signal hideRequested
signal editAppRequested(var app) signal editAppRequested(var app)
onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow)
Component.onDestruction: transientSurfaceTracker?.unregister(root)
Connections {
target: root.transientSurfaceTracker
ignoreUnknownSignals: true
function onCloseRequested() {
root.hide();
}
}
TextMetrics { TextMetrics {
id: menuTextMetrics id: menuTextMetrics
text: root.longestMenuText text: root.longestMenuText
@@ -518,8 +531,8 @@ Item {
height: root.effectiveMenuHeight height: root.effectiveMenuHeight
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium border.color: BlurService.borderColor
border.width: BlurService.enabled ? BlurService.borderWidth : 1 border.width: BlurService.borderWidth
opacity: root.openState ? 1 : 0 opacity: root.openState ? 1 : 0
Behavior on opacity { Behavior on opacity {
@@ -15,6 +15,7 @@ Item {
property var _visualRows: [] property var _visualRows: []
property var _flatIndexToRowMap: ({}) property var _flatIndexToRowMap: ({})
property var _cumulativeHeights: [] property var _cumulativeHeights: []
property var transientSurfaceTracker: null
readonly property bool _bottomSectionHeaderActive: leadingSectionHeaderAtBottom && (controller?.sections?.length ?? 0) > 0 readonly property bool _bottomSectionHeaderActive: leadingSectionHeaderAtBottom && (controller?.sections?.length ?? 0) > 0
signal itemRightClicked(int index, var item, real mouseX, real mouseY) signal itemRightClicked(int index, var item, real mouseX, real mouseY)
@@ -259,6 +260,7 @@ Item {
return root.controller?.canChangeSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? false; return root.controller?.canChangeSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? false;
} }
canCollapse: root.controller?.canCollapseSection(delegateRoot.modelData?.sectionId ?? "") ?? false canCollapse: root.controller?.canCollapseSection(delegateRoot.modelData?.sectionId ?? "") ?? false
transientSurfaceTracker: root.transientSurfaceTracker
} }
ResultItem { ResultItem {
@@ -447,6 +449,7 @@ Item {
return root.controller?.canCollapseSection(stickyHeader.stickyHeaderSection?.id) ?? false; return root.controller?.canCollapseSection(stickyHeader.stickyHeaderSection?.id) ?? false;
} }
isSticky: true isSticky: true
transientSurfaceTracker: root.transientSurfaceTracker
} }
} }
@@ -472,6 +475,7 @@ Item {
canCollapse: root.controller?.canCollapseSection(section?.id ?? "") ?? false canCollapse: root.controller?.canCollapseSection(section?.id ?? "") ?? false
isSticky: true isSticky: true
popupAbove: true popupAbove: true
transientSurfaceTracker: root.transientSurfaceTracker
} }
Item { Item {
@@ -17,9 +17,21 @@ Rectangle {
property bool isSticky: false property bool isSticky: false
property bool popupAbove: false property bool popupAbove: false
property Item popupAboveItem: null property Item popupAboveItem: null
property var transientSurfaceTracker: null
signal viewModeToggled signal viewModeToggled
Component.onDestruction: transientSurfaceTracker?.unregister(root)
Connections {
target: root.transientSurfaceTracker
ignoreUnknownSignals: true
function onCloseRequested() {
categoryPopup.close();
}
}
width: parent?.width ?? 200 width: parent?.width ?? 200
height: 32 height: 32
color: isSticky ? Theme.withAlpha(Theme.surfaceHover, 0) : (hoverArea.containsMouse ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceHover, 0)) color: isSticky ? Theme.withAlpha(Theme.surfaceHover, 0) : (hoverArea.containsMouse ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceHover, 0))
@@ -142,6 +154,8 @@ Rectangle {
dim: false dim: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
onVisibleChanged: root.transientSurfaceTracker?.setActive(root, visible, null)
background: Rectangle { background: Rectangle {
color: "transparent" color: "transparent"
} }
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
@@ -13,6 +12,7 @@ FocusScope {
property alias searchField: searchInput property alias searchField: searchInput
property alias controller: searchController property alias controller: searchController
readonly property alias activeContextMenu: contextMenu readonly property alias activeContextMenu: contextMenu
property var transientSurfaceTracker: null
readonly property bool _hasQuery: searchInput.text.length > 0 readonly property bool _hasQuery: searchInput.text.length > 0
readonly property real _searchBarH: 56 readonly property real _searchBarH: 56
@@ -49,7 +49,7 @@ FocusScope {
} }
function closeTransientUi() { function closeTransientUi() {
contextMenu.hide(); transientSurfaceTracker?.closeAll?.();
root.enabled = true; root.enabled = true;
} }
@@ -221,6 +221,7 @@ FocusScope {
searchField: searchInput searchField: searchInput
parentHandler: root parentHandler: root
allowEditActions: false allowEditActions: false
transientSurfaceTracker: root.transientSurfaceTracker
} }
Connections { Connections {
@@ -319,13 +320,13 @@ FocusScope {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
radius: height / 2 radius: height / 2
color: categoryChip.isSelected ? Theme.primary : chipArea.containsMouse ? Theme.surfaceHover : Theme.surfaceVariantAlpha color: chipColor.value
Behavior on color { DankColorAnimation {
ColorAnimation { id: chipColor
duration: root._fastDuration to: categoryChip.isSelected ? Theme.primary : chipArea.containsMouse ? Theme.surfaceHover : Theme.surfaceVariantAlpha
easing.type: Theme.standardEasing duration: root._fastDuration
} easingType: Theme.standardEasing
} }
StyledText { StyledText {
@@ -89,8 +89,8 @@ Popup {
contentItem: Rectangle { contentItem: Rectangle {
color: Theme.floatingSurface color: Theme.floatingSurface
radius: Theme.cornerRadius radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium border.color: BlurService.borderColor
border.width: BlurService.enabled ? BlurService.borderWidth : 1 border.width: BlurService.borderWidth
Column { Column {
id: menuColumn id: menuColumn
+7 -7
View File
@@ -191,21 +191,21 @@ FloatingWindow {
width: isActive ? dotSize * 3 : dotSize width: isActive ? dotSize * 3 : dotSize
height: dotSize height: dotSize
radius: dotSize / 2 radius: dotSize / 2
color: isActive ? Theme.primary : Theme.surfaceTextAlpha color: dotColor.value
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
DankColorAnimation {
id: dotColor
to: isActive ? Theme.primary : Theme.surfaceTextAlpha
duration: Theme.shortDuration
}
Behavior on width { Behavior on width {
NumberAnimation { NumberAnimation {
duration: Theme.shortDuration duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing easing.type: Theme.emphasizedEasing
} }
} }
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
}
}
} }
} }
} }
+2 -2
View File
@@ -371,7 +371,7 @@ DankModal {
} }
StyledText { StyledText {
text: I18n.tr("Create a new %1 session (n)").arg(MuxService.displayName) text: I18n.tr("Create a new %1 session (^N)").arg(MuxService.displayName)
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
@@ -549,7 +549,7 @@ DankModal {
} }
StyledText { StyledText {
text: muxModal.searchText.length > 0 ? I18n.tr("Try a different search") : I18n.tr("Press 'n' or click 'New Session' to create one") text: muxModal.searchText.length > 0 ? I18n.tr("Try a different search") : I18n.tr("Press Ctrl+N or click 'New Session' to create one")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
+3
View File
@@ -210,12 +210,14 @@ DankModal {
NotificationHeader { NotificationHeader {
id: notificationHeader id: notificationHeader
keyboardController: modalKeyboardController keyboardController: modalKeyboardController
transientSurfaceTracker: notificationModal.transientSurfaceTracker
onCurrentTabChanged: notificationModal.currentTab = currentTab onCurrentTabChanged: notificationModal.currentTab = currentTab
Component.onCompleted: notificationModal.notificationHeaderRef = notificationHeader Component.onCompleted: notificationModal.notificationHeaderRef = notificationHeader
} }
NotificationSettings { NotificationSettings {
id: notificationSettings id: notificationSettings
transientSurfaceTracker: notificationModal.transientSurfaceTracker
expanded: notificationHeader.showSettings expanded: notificationHeader.showSettings
} }
@@ -225,6 +227,7 @@ DankModal {
height: parent.height - y height: parent.height - y
visible: notificationHeader.currentTab === 0 visible: notificationHeader.currentTab === 0
keyboardController: modalKeyboardController keyboardController: modalKeyboardController
transientSurfaceTracker: notificationModal.transientSurfaceTracker
Component.onCompleted: { Component.onCompleted: {
notificationModal.notificationListRef = notificationList; notificationModal.notificationListRef = notificationList;
if (modalKeyboardController) { if (modalKeyboardController) {
+37 -3
View File
@@ -26,6 +26,8 @@ DankModal {
property int holdActionIndex: -1 property int holdActionIndex: -1
property real holdProgress: 0 property real holdProgress: 0
property bool showHoldHint: false property bool showHoldHint: false
property bool holdFromKeyboard: false
property string pendingKeyAction: ""
readonly property bool needsConfirmation: SettingsData.powerActionConfirm readonly property bool needsConfirmation: SettingsData.powerActionConfirm
readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000 readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000
@@ -39,6 +41,10 @@ DankModal {
function startHold(action, actionIndex) { function startHold(action, actionIndex) {
if (!needsConfirmation || !actionNeedsConfirm(action)) { if (!needsConfirmation || !actionNeedsConfirm(action)) {
if (holdFromKeyboard) {
pendingKeyAction = action;
return;
}
executeAction(action); executeAction(action);
return; return;
} }
@@ -50,6 +56,7 @@ DankModal {
} }
function cancelHold() { function cancelHold() {
pendingKeyAction = "";
if (holdAction === "") if (holdAction === "")
return; return;
const wasHolding = holdProgress > 0; const wasHolding = holdProgress > 0;
@@ -68,8 +75,12 @@ DankModal {
cancelHold(); cancelHold();
return; return;
} }
const action = holdAction;
holdTimer.stop(); holdTimer.stop();
if (holdFromKeyboard) {
pendingKeyAction = holdAction;
return;
}
const action = holdAction;
holdAction = ""; holdAction = "";
holdActionIndex = -1; holdActionIndex = -1;
holdProgress = 0; holdProgress = 0;
@@ -274,6 +285,8 @@ DankModal {
holdActionIndex = -1; holdActionIndex = -1;
holdProgress = 0; holdProgress = 0;
showHoldHint = false; showHoldHint = false;
holdFromKeyboard = false;
pendingKeyAction = "";
updateVisibleActions(); updateVisibleActions();
const defaultIndex = getDefaultActionIndex(); const defaultIndex = getDefaultActionIndex();
selectedIndex = defaultIndex; selectedIndex = defaultIndex;
@@ -296,6 +309,7 @@ DankModal {
event.accepted = true; event.accepted = true;
return; return;
} }
holdFromKeyboard = true;
if (SettingsData.powerMenuGridLayout) { if (SettingsData.powerMenuGridLayout) {
handleGridNavigation(event, true); handleGridNavigation(event, true);
} else { } else {
@@ -317,7 +331,16 @@ DankModal {
function handleListNavigation(event, isPressed) { function handleListNavigation(event, isPressed) {
if (!isPressed) { if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold(); if (pendingKeyAction !== "") {
const action = pendingKeyAction;
pendingKeyAction = "";
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
} else {
cancelHold();
}
event.accepted = true; event.accepted = true;
} }
return; return;
@@ -411,7 +434,16 @@ DankModal {
function handleGridNavigation(event, isPressed) { function handleGridNavigation(event, isPressed) {
if (!isPressed) { if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold(); if (pendingKeyAction !== "") {
const action = pendingKeyAction;
pendingKeyAction = "";
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
} else {
cancelHold();
}
event.accepted = true; event.accepted = true;
} }
return; return;
@@ -645,6 +677,7 @@ DankModal {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: { onPressed: {
root.holdFromKeyboard = false;
root.selectedRow = Math.floor(index / root.gridColumns); root.selectedRow = Math.floor(index / root.gridColumns);
root.selectedCol = index % root.gridColumns; root.selectedCol = index % root.gridColumns;
root.selectedIndex = index; root.selectedIndex = index;
@@ -792,6 +825,7 @@ DankModal {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: { onPressed: {
root.holdFromKeyboard = false;
root.selectedIndex = index; root.selectedIndex = index;
root.startHold(modelData, index); root.startHold(modelData, index);
} }
@@ -122,6 +122,7 @@ DankPopout {
anchors.fill: parent anchors.fill: parent
parentModal: modalAdapter parentModal: modalAdapter
viewModeContext: "appDrawer" viewModeContext: "appDrawer"
transientSurfaceTracker: appDrawerPopout.transientSurfaceTracker
} }
Keys.onEscapePressed: event => { Keys.onEscapePressed: event => {
@@ -393,6 +393,10 @@ Item {
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
if (!hist || hist.length < 2) if (!hist || hist.length < 2)
return; return;
var r = Math.max(0, tile.radius);
ctx.beginPath();
ctx.roundedRect(0, 0, width, height, r, r);
ctx.clip();
var maxVal = 100; var maxVal = 100;
if (tile.tileType === "net" || tile.tileType === "disk") { if (tile.tileType === "net" || tile.tileType === "disk") {
maxVal = 1; maxVal = 1;
@@ -314,6 +314,7 @@ DankPopout {
id: bluetoothDetailComponent id: bluetoothDetailComponent
BluetoothDetail { BluetoothDetail {
id: bluetoothDetail id: bluetoothDetail
bluetoothCodecModalRef: contentLoader.item ? contentLoader.item.bluetoothCodecSelector : null
onShowCodecSelector: function (device) { onShowCodecSelector: function (device) {
if (contentLoader.item && contentLoader.item.bluetoothCodecSelector) { if (contentLoader.item && contentLoader.item.bluetoothCodecSelector) {
contentLoader.item.bluetoothCodecSelector.show(device); contentLoader.item.bluetoothCodecSelector.show(device);

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