1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-14 00:02:45 -04:00

Compare commits

...

46 Commits

Author SHA1 Message Date
Marcus Ramberg 8fad2826b1 ci: add flake check 2025-12-08 16:09:16 +01:00
bbedward 57ee0fb2bd bump: failed fprint tries 2025-12-08 10:02:53 -05:00
osscar 3ef10e73a5 nix: remove leading dot in nativeBuildInputs (#948)
Co-authored-by: osscar <osscar.unheard025@passmail.net>
2025-12-08 15:52:32 +01:00
bbedward dc40492fc7 cc: fix audio slider binding 2025-12-08 09:45:25 -05:00
bbedward e606a76a86 screenshot: add screenshot-window support for DWL/MangoWC 2025-12-08 09:39:42 -05:00
Lucas 8838fd67b9 nix: add dev-shell (#944)
* nix: add dev-shell

* docs: add Nix dev shell in contributing docs
2025-12-08 12:22:07 +01:00
Lucas c570e20308 nix: use quickshell from source by default in greeter (#941) 2025-12-08 07:37:29 +01:00
bbedward 0a00ef39e3 ipc: fix bar widget IPCs when screens change 2025-12-07 23:15:24 -05:00
bbedward 9a08b81214 dankinstall: swap to systemd by default, use 90-dms.conf for vars 2025-12-07 22:51:22 -05:00
bbedward c617ae26a2 niri: fix some keybind tab issues
- Fix args for screenshot
- move-column stuff is focus=true by default
- Parsing fixes
part of #914
2025-12-07 22:41:01 -05:00
Lucas f6a776a692 nix: use by default quickshell from source (#939) 2025-12-07 21:11:22 -05:00
bbedward 54b253099d dankinstall: update hyprland syntax
fixes #913
2025-12-07 21:03:24 -05:00
bbedward f662aca58c dankinstall: replace grim+slurp+grimblast with dms 2025-12-07 20:59:46 -05:00
bbedward 76e7755496 consistent icon sizing 2025-12-07 20:21:07 -05:00
bbedward e05ad81c13 displays: remove system tray per-display opt
- superceded by omegabar
2025-12-07 20:13:40 -05:00
bbedward cffb16d7f7 matugen: make signalByName helper not use exec 2025-12-07 20:10:31 -05:00
bbedward 18ca571944 matugen: scrap shell script for proper backend implementation with queue
system
2025-12-07 20:00:43 -05:00
bbedward 3ae1973e21 screenshot/colorpicker: fix scaling, update go-wayland to fix object
destruction, fix hyprland window detection
2025-12-07 13:44:35 -05:00
bbedward 308c8c3ea7 lock screen: fix inconsistency with network status, add VPN
maybe fix #926
2025-12-07 12:33:29 -05:00
bbedward f49b5dd037 media player: replace color quantizer with album art 2025-12-07 12:23:00 -05:00
bbedward f245ba82ad gamma: fix non-automation toggling
fixes #924
2025-12-07 12:02:50 -05:00
arfan 60d22d6973 feat: add workspace index display when app icon enabled (#936) 2025-12-07 11:48:48 -05:00
Farokh d6f48a82d9 Update VSCode color theme templates for improved contrast and readability (#931)
* matugen/vscode-theme: update VSCode templates for contrast and readability

* vscode-theme: rework dark theme, refine light, restore default fallback

* dank16: add variants option, make default vscode consistent, fix termial
always dark

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2025-12-07 11:47:25 -05:00
Marcus Ramberg c0d73dae67 fix: handle ipc arguments (#930) 2025-12-07 11:01:31 -05:00
Marcus Ramberg 49eb60589d fix: also restart ghostty/kitty on nix (#934) 2025-12-07 10:28:26 -05:00
Marcus Ramberg 89993b7421 core: remove unused function after refactors (#935) 2025-12-07 10:27:44 -05:00
purian23 511cb93806 Update rebuild logic on automation to obs / ppa 2025-12-06 21:33:53 -05:00
purian23 8ce78e7134 Dependency removals from Dankinstaller Distros
- Removed grim, grimblast, slurp, hyprpicker & mate-polkit from all distros
2025-12-06 01:10:13 -05:00
Yuxiang Qiu 9ebfab2e78 brightness: rescan brightness (#922) 2025-12-06 00:24:54 -05:00
bbedward 833d245251 dankbar: fix centersection positioning 2025-12-05 23:59:06 -05:00
bbedward 00d3024143 dankbar: keep border on maximize 2025-12-05 23:50:27 -05:00
bbedward aedeab8a6a screenshot: add window capture for Hyprland 2025-12-05 21:10:12 -05:00
Pi Home Server 4d39169eb8 Feature/control center widget fix (#912)
* Add a widget to display the power menu

* Update power button widget

* Upate based on new settings

* Rollback to DisplaysTab.qml
2025-12-05 20:29:39 -05:00
bbedward 2ddc448150 screenshot: ensure screencopy before surface creation 2025-12-05 17:39:35 -05:00
bbedward f9a6b4ce2c colorpick/screenshot: make color-format aware 2025-12-05 17:26:38 -05:00
bbedward 22b2b69413 screenshot: add shift to perfect-square capability 2025-12-05 17:08:00 -05:00
bbedward 7f11632ea6 screenshot: fix notif content to show open file browser 2025-12-05 16:56:29 -05:00
bbedward c0b4d5e2c2 screenshot: fix thumbnail preview 2025-12-05 16:16:13 -05:00
Lucas 2c23d0249c nix: match upstream package format (#918) 2025-12-05 16:11:18 -05:00
bbedward c3233fbf61 power menu: shorter hold durations 2025-12-05 16:05:11 -05:00
bbedward ecfc8e208c screenshot: clipboard by default 2025-12-05 15:59:37 -05:00
bbedward 52d5e21fc4 screenshot: fix some region mappings 2025-12-05 15:25:27 -05:00
bbedward 6d0c56554f core: add screenshot utility 2025-12-05 14:59:34 -05:00
bbedward 844e91dc9e controlcenter: default vpn button to on 2025-12-05 14:21:19 -05:00
bbedward 1f00b5f577 fix some stale screen ref issues in OSD and popout 2025-12-05 13:31:57 -05:00
bbedward 2c48458384 brightness: more aggressive ddc rescans on device changes 2025-12-05 13:18:10 -05:00
108 changed files with 7595 additions and 2981 deletions
+30
View File
@@ -0,0 +1,30 @@
name: Check nix flake
on:
pull_request:
branches: [master, main]
paths:
- "flake.*"
- "distro/nix/**"
jobs:
check-flake:
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Install Nix
uses: cachix/install-nix-action@v31
- name: Update vendorHash in flake.nix
run: nix flake check
+4 -33
View File
@@ -102,39 +102,6 @@ go.work.sum
# .idea/
# .vscode/
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
bin/
# Extracted source trees in Ubuntu package directories
@@ -142,3 +109,7 @@ distro/ubuntu/*/dms-git-repo/
distro/ubuntu/*/DankMaterialShell-*/
distro/ubuntu/danklinux/*/dsearch-*/
distro/ubuntu/danklinux/*/dgop-*/
# direnv
.envrc
.direnv/
+15
View File
@@ -12,6 +12,21 @@ Enable pre-commit hooks to catch CI failures before pushing:
git config core.hooksPath .githooks
```
### Nix Development Shell
If you have Nix installed with flakes enabled, you can use the provided development shell which includes all necessary dependencies:
```bash
nix develop
```
This will provide:
- Go 1.24 toolchain (go, gopls, delve, go-tools) and GNU Make
- Quickshell and required QML packages
- Properly configured QML2_IMPORT_PATH
The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory.
## VSCode Setup
This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on.
+1
View File
@@ -21,6 +21,7 @@ linters:
# Signal handling
- (*os.Process).Signal
- (*os.Process).Kill
- syscall.Kill
# DBus cleanup
- (*github.com/godbus/dbus/v5.Conn).RemoveMatchSignal
- (*github.com/godbus/dbus/v5.Conn).RemoveSignal
+3 -1
View File
@@ -454,7 +454,6 @@ func uninstallPluginCLI(idOrName string) error {
return nil
}
// getCommonCommands returns the commands available in all builds
func getCommonCommands() []*cobra.Command {
return []*cobra.Command{
versionCmd,
@@ -472,5 +471,8 @@ func getCommonCommands() []*cobra.Command {
greeterCmd,
setupCmd,
colorCmd,
screenshotCmd,
notifyActionCmd,
matugenCmd,
}
}
+47 -8
View File
@@ -10,15 +10,15 @@ import (
)
var dank16Cmd = &cobra.Command{
Use: "dank16 <hex_color>",
Use: "dank16 [hex_color]",
Short: "Generate Base16 color palettes",
Long: "Generate Base16 color palettes from a color with support for various output formats",
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
Run: runDank16,
}
func init() {
dank16Cmd.Flags().Bool("light", false, "Generate light theme variant")
dank16Cmd.Flags().Bool("light", false, "Generate light theme variant (sets default to light)")
dank16Cmd.Flags().Bool("json", false, "Output in JSON format")
dank16Cmd.Flags().Bool("kitty", false, "Output in Kitty terminal format")
dank16Cmd.Flags().Bool("foot", false, "Output in Foot terminal format")
@@ -27,17 +27,15 @@ func init() {
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
dank16Cmd.Flags().String("background", "", "Custom background color")
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON")
dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)")
dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)")
_ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp
})
}
func runDank16(cmd *cobra.Command, args []string) {
primaryColor := args[0]
if !strings.HasPrefix(primaryColor, "#") {
primaryColor = "#" + primaryColor
}
isLight, _ := cmd.Flags().GetBool("light")
isJson, _ := cmd.Flags().GetBool("json")
isKitty, _ := cmd.Flags().GetBool("kitty")
@@ -47,16 +45,57 @@ func runDank16(cmd *cobra.Command, args []string) {
isWezterm, _ := cmd.Flags().GetBool("wezterm")
background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast")
useVariants, _ := cmd.Flags().GetBool("variants")
primaryDark, _ := cmd.Flags().GetString("primary-dark")
primaryLight, _ := cmd.Flags().GetString("primary-light")
if background != "" && !strings.HasPrefix(background, "#") {
background = "#" + background
}
if primaryDark != "" && !strings.HasPrefix(primaryDark, "#") {
primaryDark = "#" + primaryDark
}
if primaryLight != "" && !strings.HasPrefix(primaryLight, "#") {
primaryLight = "#" + primaryLight
}
contrastAlgo = strings.ToLower(contrastAlgo)
if contrastAlgo != "dps" && contrastAlgo != "wcag" {
log.Fatalf("Invalid contrast algorithm: %s (must be 'dps' or 'wcag')", contrastAlgo)
}
if useVariants {
if primaryDark == "" || primaryLight == "" {
if len(args) == 0 {
log.Fatalf("--variants requires either a positional color argument or both --primary-dark and --primary-light")
}
primaryColor := args[0]
if !strings.HasPrefix(primaryColor, "#") {
primaryColor = "#" + primaryColor
}
primaryDark = primaryColor
primaryLight = primaryColor
}
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors))
return
}
if len(args) == 0 {
log.Fatalf("A color argument is required (or use --variants with --primary-dark and --primary-light)")
}
primaryColor := args[0]
if !strings.HasPrefix(primaryColor, "#") {
primaryColor = "#" + primaryColor
}
opts := dank16.PaletteOptions{
IsLight: isLight,
Background: background,
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/spf13/cobra"
)
var matugenCmd = &cobra.Command{
Use: "matugen",
Short: "Generate Material Design themes",
Long: "Generate Material Design themes using matugen with dank16 color integration",
}
var matugenGenerateCmd = &cobra.Command{
Use: "generate",
Short: "Generate theme synchronously",
Run: runMatugenGenerate,
}
var matugenQueueCmd = &cobra.Command{
Use: "queue",
Short: "Queue theme generation (uses socket if available)",
Run: runMatugenQueue,
}
func init() {
matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files")
cmd.Flags().String("shell-dir", "", "DMS shell installation directory")
cmd.Flags().String("config-dir", "", "User config directory")
cmd.Flags().String("kind", "image", "Source type: image or hex")
cmd.Flags().String("value", "", "Wallpaper path or hex color")
cmd.Flags().String("mode", "dark", "Color mode: dark or light")
cmd.Flags().String("icon-theme", "System Default", "Icon theme name")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type")
cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates")
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
}
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 30*time.Second, "Timeout for waiting")
}
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
stateDir, _ := cmd.Flags().GetString("state-dir")
shellDir, _ := cmd.Flags().GetString("shell-dir")
configDir, _ := cmd.Flags().GetString("config-dir")
kind, _ := cmd.Flags().GetString("kind")
value, _ := cmd.Flags().GetString("value")
mode, _ := cmd.Flags().GetString("mode")
iconTheme, _ := cmd.Flags().GetString("icon-theme")
matugenType, _ := cmd.Flags().GetString("matugen-type")
runUserTemplates, _ := cmd.Flags().GetBool("run-user-templates")
stockColors, _ := cmd.Flags().GetString("stock-colors")
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
return matugen.Options{
StateDir: stateDir,
ShellDir: shellDir,
ConfigDir: configDir,
Kind: kind,
Value: value,
Mode: mode,
IconTheme: iconTheme,
MatugenType: matugenType,
RunUserTemplates: runUserTemplates,
StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark,
}
}
func runMatugenGenerate(cmd *cobra.Command, args []string) {
opts := buildMatugenOptions(cmd)
if err := matugen.Run(opts); err != nil {
log.Fatalf("Theme generation failed: %v", err)
}
}
func runMatugenQueue(cmd *cobra.Command, args []string) {
opts := buildMatugenOptions(cmd)
wait, _ := cmd.Flags().GetBool("wait")
timeout, _ := cmd.Flags().GetDuration("timeout")
socketPath := os.Getenv("DMS_SOCKET")
if socketPath == "" {
var err error
socketPath, err = server.FindSocket()
if err != nil {
log.Info("No socket available, running synchronously")
if err := matugen.Run(opts); err != nil {
log.Fatalf("Theme generation failed: %v", err)
}
return
}
}
conn, err := net.Dial("unix", socketPath)
if err != nil {
log.Info("Socket connection failed, running synchronously")
if err := matugen.Run(opts); err != nil {
log.Fatalf("Theme generation failed: %v", err)
}
return
}
defer conn.Close()
request := map[string]any{
"id": 1,
"method": "matugen.queue",
"params": map[string]any{
"stateDir": opts.StateDir,
"shellDir": opts.ShellDir,
"configDir": opts.ConfigDir,
"kind": opts.Kind,
"value": opts.Value,
"mode": opts.Mode,
"iconTheme": opts.IconTheme,
"matugenType": opts.MatugenType,
"runUserTemplates": opts.RunUserTemplates,
"stockColors": opts.StockColors,
"syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"wait": wait,
},
}
if err := json.NewEncoder(conn).Encode(request); err != nil {
log.Fatalf("Failed to send request: %v", err)
}
if !wait {
fmt.Println("Theme generation queued")
return
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
resultCh := make(chan error, 1)
go func() {
var response struct {
ID int `json:"id"`
Result any `json:"result"`
Error string `json:"error"`
}
if err := json.NewDecoder(conn).Decode(&response); err != nil {
resultCh <- fmt.Errorf("failed to read response: %w", err)
return
}
if response.Error != "" {
resultCh <- fmt.Errorf("server error: %s", response.Error)
return
}
resultCh <- nil
}()
select {
case err := <-resultCh:
if err != nil {
log.Fatalf("Theme generation failed: %v", err)
}
fmt.Println("Theme generation completed")
case <-ctx.Done():
log.Fatalf("Timeout waiting for theme generation")
}
}
+376
View File
@@ -0,0 +1,376 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
"github.com/spf13/cobra"
)
var (
ssOutputName string
ssIncludeCursor bool
ssFormat string
ssQuality int
ssOutputDir string
ssFilename string
ssNoClipboard bool
ssNoFile bool
ssNoNotify bool
ssStdout bool
)
var screenshotCmd = &cobra.Command{
Use: "screenshot",
Short: "Capture screenshots",
Long: `Capture screenshots from Wayland displays.
Modes:
region - Select a region interactively (default)
full - Capture the focused output
all - Capture all outputs combined
output - Capture a specific output by name
window - Capture the focused window (Hyprland/DWL)
last - Capture the last selected region
Output format (--format):
png - PNG format (default)
jpg/jpeg - JPEG format
ppm - PPM format
Examples:
dms screenshot # Region select, save file + clipboard
dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window (Hyprland)
dms screenshot last # Last region (pre-selected)
dms screenshot --no-clipboard # Save file only
dms screenshot --no-file # Clipboard only
dms screenshot --cursor # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
}
var ssRegionCmd = &cobra.Command{
Use: "region",
Short: "Select a region interactively",
Run: runScreenshotRegion,
}
var ssFullCmd = &cobra.Command{
Use: "full",
Short: "Capture the focused output",
Run: runScreenshotFull,
}
var ssAllCmd = &cobra.Command{
Use: "all",
Short: "Capture all outputs combined",
Run: runScreenshotAll,
}
var ssOutputCmd = &cobra.Command{
Use: "output",
Short: "Capture a specific output",
Run: runScreenshotOutput,
}
var ssLastCmd = &cobra.Command{
Use: "last",
Short: "Capture the last selected region",
Long: `Capture the previously selected region without interactive selection.
If no previous region exists, falls back to interactive selection.`,
Run: runScreenshotLast,
}
var ssWindowCmd = &cobra.Command{
Use: "window",
Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland and DWL.`,
Run: runScreenshotWindow,
}
var ssListCmd = &cobra.Command{
Use: "list",
Short: "List available outputs",
Run: runScreenshotList,
}
var notifyActionCmd = &cobra.Command{
Use: "notify-action",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
screenshot.RunNotifyActionListener(args)
},
}
func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().BoolVar(&ssIncludeCursor, "cursor", false, "Include cursor in screenshot")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory")
screenshotCmd.PersistentFlags().StringVar(&ssFilename, "filename", "", "Output filename (auto-generated if empty)")
screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard")
screenshotCmd.PersistentFlags().BoolVar(&ssNoFile, "no-file", false, "Don't save to file")
screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification")
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.AddCommand(ssRegionCmd)
screenshotCmd.AddCommand(ssFullCmd)
screenshotCmd.AddCommand(ssAllCmd)
screenshotCmd.AddCommand(ssOutputCmd)
screenshotCmd.AddCommand(ssLastCmd)
screenshotCmd.AddCommand(ssWindowCmd)
screenshotCmd.AddCommand(ssListCmd)
screenshotCmd.Run = runScreenshotRegion
}
func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig()
config.Mode = mode
config.OutputName = ssOutputName
config.IncludeCursor = ssIncludeCursor
config.Clipboard = !ssNoClipboard
config.SaveFile = !ssNoFile
config.Notify = !ssNoNotify
config.Stdout = ssStdout
if ssOutputDir != "" {
config.OutputDir = ssOutputDir
}
if ssFilename != "" {
config.Filename = ssFilename
}
switch strings.ToLower(ssFormat) {
case "jpg", "jpeg":
config.Format = screenshot.FormatJPEG
case "ppm":
config.Format = screenshot.FormatPPM
default:
config.Format = screenshot.FormatPNG
}
if ssQuality < 1 {
ssQuality = 1
}
if ssQuality > 100 {
ssQuality = 100
}
config.Quality = ssQuality
return config
}
func runScreenshot(config screenshot.Config) {
sc := screenshot.New(config)
result, err := sc.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if result == nil {
os.Exit(0)
}
defer result.Buffer.Close()
if result.YInverted {
result.Buffer.FlipVertical()
}
if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
os.Exit(1)
}
return
}
var filePath string
if config.SaveFile {
outputDir := config.OutputDir
if outputDir == "" {
outputDir = screenshot.GetOutputDir()
}
filename := config.Filename
if filename == "" {
filename = screenshot.GenerateFilename(config.Format)
}
filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Println(filePath)
}
if config.Clipboard {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
os.Exit(1)
}
if !config.SaveFile {
fmt.Println("Copied to clipboard")
}
}
if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
screenshot.SendNotification(screenshot.NotifyResult{
FilePath: filePath,
Clipboard: config.Clipboard,
ImageData: thumbData,
Width: thumbW,
Height: thumbH,
})
}
}
func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
var mimeType string
var data bytes.Buffer
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format {
case screenshot.FormatJPEG:
mimeType = "image/jpeg"
if err := screenshot.EncodeJPEG(&data, img, quality); err != nil {
return err
}
default:
mimeType = "image/png"
if err := screenshot.EncodePNG(&data, img); err != nil {
return err
}
}
cmd := exec.Command("wl-copy", "--type", mimeType)
cmd.Stdin = &data
return cmd.Run()
}
func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format {
case screenshot.FormatJPEG:
return screenshot.EncodeJPEG(os.Stdout, img, quality)
default:
return screenshot.EncodePNG(os.Stdout, img)
}
}
func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
srcW, srcH := buf.Width, buf.Height
scale := 1.0
if srcW > maxSize || srcH > maxSize {
if srcW > srcH {
scale = float64(maxSize) / float64(srcW)
} else {
scale = float64(maxSize) / float64(srcH)
}
}
dstW := int(float64(srcW) * scale)
dstH := int(float64(srcH) * scale)
if dstW < 1 {
dstW = 1
}
if dstH < 1 {
dstH = 1
}
data := buf.Data()
rgb := make([]byte, dstW*dstH*3)
swapRB := pixelFormat == uint32(screenshot.FormatARGB8888) || pixelFormat == uint32(screenshot.FormatXRGB8888) || pixelFormat == 0
for y := 0; y < dstH; y++ {
srcY := int(float64(y) / scale)
if srcY >= srcH {
srcY = srcH - 1
}
for x := 0; x < dstW; x++ {
srcX := int(float64(x) / scale)
if srcX >= srcW {
srcX = srcW - 1
}
si := srcY*buf.Stride + srcX*4
di := (y*dstW + x) * 3
if si+2 < len(data) {
if swapRB {
rgb[di+0] = data[si+2]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+0]
} else {
rgb[di+0] = data[si+0]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+2]
}
}
}
}
return rgb, dstW, dstH
}
func runScreenshotRegion(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeRegion)
runScreenshot(config)
}
func runScreenshotFull(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeFullScreen)
runScreenshot(config)
}
func runScreenshotAll(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeAllScreens)
runScreenshot(config)
}
func runScreenshotOutput(cmd *cobra.Command, args []string) {
if ssOutputName == "" && len(args) > 0 {
ssOutputName = args[0]
}
if ssOutputName == "" {
fmt.Fprintln(os.Stderr, "Error: output name required (use -o or provide as argument)")
os.Exit(1)
}
config := getScreenshotConfig(screenshot.ModeOutput)
runScreenshot(config)
}
func runScreenshotLast(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeLastRegion)
runScreenshot(config)
}
func runScreenshotWindow(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeWindow)
runScreenshot(config)
}
func runScreenshotList(cmd *cobra.Command, args []string) {
outputs, err := screenshot.ListOutputs()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
for _, o := range outputs {
fmt.Printf("%s: %dx%d+%d+%d (scale: %d)\n",
o.Name, o.Width, o.Height, o.X, o.Y, o.Scale)
}
}
+35 -7
View File
@@ -16,7 +16,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
)
type ipcTargets map[string][]string
type ipcTargets map[string]map[string][]string
var isSessionManaged bool
@@ -476,28 +476,40 @@ func runShellDaemon(session bool) {
}
func parseTargetsFromIPCShowOutput(output string) ipcTargets {
targets := map[string][]string{}
targets := make(ipcTargets)
var currentTarget string
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "target ") {
currentTarget = strings.TrimSpace(strings.TrimPrefix(line, "target "))
targets[currentTarget] = make(map[string][]string)
}
if strings.HasPrefix(line, " function") && currentTarget != "" {
argsList := []string{}
currentFunc := strings.TrimPrefix(line, " function ")
currentFunc = strings.SplitN(currentFunc, "(", 2)[0]
targets[currentTarget] = append(targets[currentTarget], currentFunc)
funcDef := strings.SplitN(currentFunc, "(", 2)
argList := strings.SplitN(funcDef[1], ")", 2)[0]
args := strings.Split(argList, ",")
if len(args) > 0 && strings.TrimSpace(args[0]) != "" {
argsList = append(argsList, funcDef[0])
for _, arg := range args {
argName := strings.SplitN(strings.TrimSpace(arg), ":", 2)[0]
argsList = append(argsList, argName)
}
targets[currentTarget][funcDef[0]] = argsList
} else {
targets[currentTarget][funcDef[0]] = make([]string, 0)
}
}
}
return targets
}
func getShellIPCCompletions(args []string, toComplete string) []string {
func getShellIPCCompletions(args []string, _ string) []string {
cmdArgs := []string{"-p", configPath, "ipc", "show"}
cmd := exec.Command("qs", cmdArgs...)
var targets ipcTargets
if output, err := cmd.Output(); err == nil {
log.Debugf("IPC show output: %s", string(output))
targets = parseTargetsFromIPCShowOutput(string(output))
} else {
log.Debugf("Error getting IPC show output for completions: %v", err)
@@ -516,8 +528,24 @@ func getShellIPCCompletions(args []string, toComplete string) []string {
}
return targetNames
}
if len(args) == 1 {
if targetFuncs, ok := targets[args[0]]; ok {
funcNames := make([]string, 0)
for k := range targetFuncs {
funcNames = append(funcNames, k)
}
return funcNames
}
return nil
}
if len(args) <= len(targets[args[0]]) {
funcArgs := targets[args[0]][args[1]]
if len(funcArgs) >= len(args) {
return []string{fmt.Sprintf("[%s]", funcArgs[len(args)-1])}
}
}
return targets[args[0]]
return nil
}
func runShellIPCCommand(args []string) {
+91 -82
View File
@@ -2,7 +2,6 @@ package colorpicker
import (
"fmt"
"math"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -10,6 +9,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
@@ -33,15 +33,19 @@ type Output struct {
}
type LayerSurface struct {
output *Output
state *SurfaceState
wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport
wlPool *client.ShmPool
wlBuffer *client.Buffer
configured bool
hidden bool
output *Output
state *SurfaceState
wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport
wlPool *client.ShmPool
wlBuffer *client.Buffer
bufferBusy bool
oldPool *client.ShmPool
oldBuffer *client.Buffer
scopyBuffer *client.Buffer
configured bool
hidden bool
}
type Picker struct {
@@ -111,6 +115,11 @@ func (p *Picker) Run() (*Color, error) {
return nil, fmt.Errorf("roundtrip: %w", err)
}
// Extra roundtrip to ensure pointer/keyboard from seat capabilities are registered
if err := p.roundtrip(); err != nil {
return nil, fmt.Errorf("roundtrip after seat: %w", err)
}
if err := p.createSurfaces(); err != nil {
return nil, fmt.Errorf("create surfaces: %w", err)
}
@@ -165,26 +174,7 @@ func (p *Picker) connect() error {
}
func (p *Picker) roundtrip() error {
callback, err := p.display.Sync()
if err != nil {
return err
}
done := make(chan struct{})
callback.SetDoneHandler(func(e client.CallbackDoneEvent) {
close(done)
})
for {
select {
case <-done:
return nil
default:
if err := p.ctx.Dispatch(); err != nil {
return err
}
}
}
return wlhelpers.Roundtrip(p.display, p.ctx)
}
func (p *Picker) setupRegistry() error {
@@ -419,15 +409,10 @@ func (p *Picker) createLayerSurface(output *Output) (*LayerSurface, error) {
func (p *Picker) computeSurfaceScale(ls *LayerSurface) int32 {
out := ls.output
if out == nil || out.fractionalScale <= 0 {
if out == nil || out.scale <= 0 {
return 1
}
scale := int32(math.Ceil(out.fractionalScale))
if scale <= 0 {
scale = 1
}
return scale
return out.scale
}
func (p *Picker) ensureShortcutsInhibitor(ls *LayerSurface) {
@@ -481,6 +466,12 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
return
}
if ls.scopyBuffer != nil {
ls.scopyBuffer.Destroy()
}
ls.scopyBuffer = wlBuffer
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {})
if err := frame.Copy(wlBuffer); err != nil {
log.Error("failed to copy frame", "err", err)
}
@@ -493,6 +484,13 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
ls.state.OnScreencopyReady()
logicalW, _ := ls.state.LogicalSize()
screenBuf := ls.state.ScreenBuffer()
if logicalW > 0 && screenBuf != nil {
ls.output.fractionalScale = float64(screenBuf.Width) / float64(logicalW)
}
scale := p.computeSurfaceScale(ls)
ls.state.SetScale(scale)
frame.Destroy()
@@ -507,7 +505,6 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
func (p *Picker) redrawSurface(ls *LayerSurface) {
var renderBuf *ShmBuffer
if ls.hidden {
// When hidden, just show the screenshot without overlay
renderBuf = ls.state.RedrawScreenOnly()
} else {
renderBuf = ls.state.Redraw()
@@ -516,65 +513,58 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
return
}
if ls.wlPool != nil {
ls.wlPool.Destroy()
ls.wlPool = nil
if ls.oldBuffer != nil {
ls.oldBuffer.Destroy()
ls.oldBuffer = nil
}
if ls.wlBuffer != nil {
ls.wlBuffer.Destroy()
ls.wlBuffer = nil
if ls.oldPool != nil {
ls.oldPool.Destroy()
ls.oldPool = nil
}
ls.oldPool = ls.wlPool
ls.oldBuffer = ls.wlBuffer
ls.wlPool = nil
ls.wlBuffer = nil
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
if err != nil {
return
}
ls.wlPool = pool
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(FormatARGB8888))
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
if err != nil {
return
}
ls.wlBuffer = wlBuffer
lsRef := ls
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
lsRef.bufferBusy = false
})
ls.bufferBusy = true
logicalW, logicalH := ls.state.LogicalSize()
if logicalW == 0 || logicalH == 0 {
logicalW = int(ls.output.width)
logicalH = int(ls.output.height)
}
scale := ls.state.Scale()
if scale <= 0 {
scale = 1
}
if ls.viewport != nil {
srcW := float64(renderBuf.Width) / float64(scale)
srcH := float64(renderBuf.Height) / float64(scale)
if err := ls.viewport.SetSource(0, 0, srcW, srcH); err != nil {
log.Warn("failed to set viewport source", "err", err)
}
if err := ls.viewport.SetDestination(int32(logicalW), int32(logicalH)); err != nil {
log.Warn("failed to set viewport destination", "err", err)
}
if err := ls.wlSurface.SetBufferScale(scale); err != nil {
log.Warn("failed to set buffer scale", "err", err)
}
_ = ls.wlSurface.SetBufferScale(1)
_ = ls.viewport.SetSource(0, 0, float64(renderBuf.Width), float64(renderBuf.Height))
_ = ls.viewport.SetDestination(int32(logicalW), int32(logicalH))
} else {
if err := ls.wlSurface.SetBufferScale(scale); err != nil {
log.Warn("failed to set buffer scale", "err", err)
bufferScale := ls.output.scale
if bufferScale <= 0 {
bufferScale = 1
}
_ = ls.wlSurface.SetBufferScale(bufferScale)
}
if err := ls.wlSurface.Attach(wlBuffer, 0, 0); err != nil {
log.Warn("failed to attach buffer", "err", err)
}
if err := ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH)); err != nil {
log.Warn("failed to damage surface", "err", err)
}
if err := ls.wlSurface.Commit(); err != nil {
log.Warn("failed to commit surface", "err", err)
}
_ = ls.wlSurface.Attach(wlBuffer, 0, 0)
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
_ = ls.wlSurface.Commit()
ls.state.SwapBuffers()
}
@@ -596,17 +586,19 @@ func (p *Picker) setupInput() {
p.seat.SetCapabilitiesHandler(func(e client.SeatCapabilitiesEvent) {
if e.Capabilities&uint32(client.SeatCapabilityPointer) != 0 && p.pointer == nil {
pointer, err := p.seat.GetPointer()
if err == nil {
p.pointer = pointer
p.setupPointerHandlers()
if err != nil {
return
}
p.pointer = pointer
p.setupPointerHandlers()
}
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && p.keyboard == nil {
keyboard, err := p.seat.GetKeyboard()
if err == nil {
p.keyboard = keyboard
p.setupKeyboardHandlers()
if err != nil {
return
}
p.keyboard = keyboard
p.setupKeyboardHandlers()
}
})
}
@@ -617,9 +609,14 @@ func (p *Picker) setupPointerHandlers() {
log.Debug("failed to hide cursor", "err", err)
}
if e.Surface == nil {
return
}
p.activeSurface = nil
surfaceID := e.Surface.ID()
for _, ls := range p.surfaces {
if ls.wlSurface.ID() == e.Surface.ID() {
if ls.wlSurface.ID() == surfaceID {
p.activeSurface = ls
break
}
@@ -628,7 +625,6 @@ func (p *Picker) setupPointerHandlers() {
return
}
// If surface was hidden, mark it as visible again
if p.activeSurface.hidden {
p.activeSurface.hidden = false
}
@@ -638,8 +634,12 @@ func (p *Picker) setupPointerHandlers() {
})
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
if e.Surface == nil {
return
}
surfaceID := e.Surface.ID()
for _, ls := range p.surfaces {
if ls.wlSurface.ID() == e.Surface.ID() {
if ls.wlSurface.ID() == surfaceID {
p.hideSurface(ls)
break
}
@@ -672,6 +672,15 @@ func (p *Picker) setupKeyboardHandlers() {
func (p *Picker) cleanup() {
for _, ls := range p.surfaces {
if ls.scopyBuffer != nil {
ls.scopyBuffer.Destroy()
}
if ls.oldBuffer != nil {
ls.oldBuffer.Destroy()
}
if ls.oldPool != nil {
ls.oldPool.Destroy()
}
if ls.wlBuffer != nil {
ls.wlBuffer.Destroy()
}
+22 -75
View File
@@ -1,93 +1,40 @@
package colorpicker
import (
"fmt"
import "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm"
"golang.org/x/sys/unix"
)
type ShmBuffer struct {
fd int
data []byte
size int
Width int
Height int
Stride int
}
type ShmBuffer = shm.Buffer
func CreateShmBuffer(width, height, stride int) (*ShmBuffer, error) {
size := stride * height
fd, err := unix.MemfdCreate("dms-colorpicker", 0)
if err != nil {
return nil, fmt.Errorf("failed to create memfd: %w", err)
}
if err := unix.Ftruncate(fd, int64(size)); err != nil {
unix.Close(fd)
return nil, fmt.Errorf("ftruncate failed: %w", err)
}
data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
unix.Close(fd)
return nil, fmt.Errorf("mmap failed: %w", err)
}
return &ShmBuffer{
fd: fd,
data: data,
size: size,
Width: width,
Height: height,
Stride: stride,
}, nil
return shm.CreateBuffer(width, height, stride)
}
func (s *ShmBuffer) Fd() int {
return s.fd
func GetPixelColor(buf *ShmBuffer, x, y int) Color {
return GetPixelColorWithFormat(buf, x, y, FormatARGB8888)
}
func (s *ShmBuffer) Size() int {
return s.size
}
func (s *ShmBuffer) Data() []byte {
return s.data
}
func (s *ShmBuffer) GetPixel(x, y int) Color {
if x < 0 || x >= s.Width || y < 0 || y >= s.Height {
func GetPixelColorWithFormat(buf *ShmBuffer, x, y int, format PixelFormat) Color {
if x < 0 || x >= buf.Width || y < 0 || y >= buf.Height {
return Color{}
}
offset := y*s.Stride + x*4
if offset+3 >= len(s.data) {
data := buf.Data()
offset := y*buf.Stride + x*4
if offset+3 >= len(data) {
return Color{}
}
if format == FormatABGR8888 || format == FormatXBGR8888 {
return Color{
R: data[offset],
G: data[offset+1],
B: data[offset+2],
A: data[offset+3],
}
}
return Color{
B: s.data[offset],
G: s.data[offset+1],
R: s.data[offset+2],
A: s.data[offset+3],
B: data[offset],
G: data[offset+1],
R: data[offset+2],
A: data[offset+3],
}
}
func (s *ShmBuffer) Close() error {
var firstErr error
if s.data != nil {
if err := unix.Munmap(s.data); err != nil && firstErr == nil {
firstErr = fmt.Errorf("munmap failed: %w", err)
}
s.data = nil
}
if s.fd >= 0 {
if err := unix.Close(s.fd); err != nil && firstErr == nil {
firstErr = fmt.Errorf("close fd failed: %w", err)
}
s.fd = -1
}
return firstErr
}
+39 -18
View File
@@ -4,15 +4,17 @@ import (
"math"
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm"
)
type PixelFormat uint32
type PixelFormat = shm.PixelFormat
const (
FormatARGB8888 PixelFormat = 0
FormatXRGB8888 PixelFormat = 1
FormatABGR8888 PixelFormat = 0x34324241
FormatXBGR8888 PixelFormat = 0x34324258
FormatARGB8888 = shm.FormatARGB8888
FormatXRGB8888 = shm.FormatXRGB8888
FormatABGR8888 = shm.FormatABGR8888
FormatXBGR8888 = shm.FormatXBGR8888
)
type SurfaceState struct {
@@ -98,6 +100,12 @@ func (s *SurfaceState) ScreenBuffer() *ShmBuffer {
return s.screenBuf
}
func (s *SurfaceState) ScreenFormat() PixelFormat {
s.mu.Lock()
defer s.mu.Unlock()
return s.screenFormat
}
func (s *SurfaceState) OnScreencopyFlags(flags uint32) {
s.mu.Lock()
s.yInverted = (flags & 1) != 0
@@ -253,7 +261,7 @@ func (s *SurfaceState) Redraw() *ShmBuffer {
return nil
}
copy(dst.data, s.screenBuf.data)
dst.CopyFrom(s.screenBuf)
px := int(math.Round(float64(s.pointerX) * s.scaleX))
py := int(math.Round(float64(s.pointerY) * s.scaleY))
@@ -261,15 +269,20 @@ func (s *SurfaceState) Redraw() *ShmBuffer {
px = clamp(px, 0, dst.Width-1)
py = clamp(py, 0, dst.Height-1)
picked := s.screenBuf.GetPixel(px, py)
sampleY := py
if s.yInverted {
sampleY = s.screenBuf.Height - 1 - py
}
drawMagnifier(
dst.data, dst.Stride, dst.Width, dst.Height,
s.screenBuf.data, s.screenBuf.Stride, s.screenBuf.Width, s.screenBuf.Height,
px, py, picked,
picked := GetPixelColorWithFormat(s.screenBuf, px, sampleY, s.screenFormat)
drawMagnifierWithInversion(
dst.Data(), dst.Stride, dst.Width, dst.Height,
s.screenBuf.Data(), s.screenBuf.Stride, s.screenBuf.Width, s.screenBuf.Height,
px, py, picked, s.yInverted,
)
drawColorPreview(dst.data, dst.Stride, dst.Width, dst.Height, px, py, picked, s.displayFormat, s.lowercase)
drawColorPreview(dst.Data(), dst.Stride, dst.Width, dst.Height, px, py, picked, s.displayFormat, s.lowercase)
return dst
}
@@ -289,7 +302,7 @@ func (s *SurfaceState) RedrawScreenOnly() *ShmBuffer {
return nil
}
copy(dst.data, s.screenBuf.data)
dst.CopyFrom(s.screenBuf)
return dst
}
@@ -311,7 +324,7 @@ func (s *SurfaceState) PickColor() (Color, bool) {
sy = s.screenBuf.Height - 1 - sy
}
return s.screenBuf.GetPixel(sx, sy), true
return GetPixelColorWithFormat(s.screenBuf, sx, sy, s.screenFormat), true
}
func (s *SurfaceState) Destroy() {
@@ -371,11 +384,12 @@ func blendColors(bg, fg Color, alpha float64) Color {
}
}
func drawMagnifier(
func drawMagnifierWithInversion(
dst []byte, dstStride, dstW, dstH int,
src []byte, srcStride, srcW, srcH int,
cx, cy int,
borderColor Color,
yInverted bool,
) {
if dstW <= 0 || dstH <= 0 || srcW <= 0 || srcH <= 0 {
return
@@ -431,10 +445,11 @@ func drawMagnifier(
finalColor = blendColors(bgColor, borderColor, alpha)
case dist > innerRadius:
if dist > outerRadiusF-aaWidth {
switch {
case dist > outerRadiusF-aaWidth:
alpha := clampF((outerRadiusF-dist)/aaWidth, 0, 1)
finalColor = blendColors(borderColor, borderColor, alpha)
} else if dist < innerRadius+aaWidth {
case dist < innerRadius+aaWidth:
alpha := clampF((dist-innerRadius)/aaWidth, 0, 1)
fx := float64(dx) / zoom
fy := float64(dy) / zoom
@@ -442,6 +457,9 @@ func drawMagnifier(
sy := cy + int(math.Round(fy))
sx = clamp(sx, 0, srcW-1)
sy = clamp(sy, 0, srcH-1)
if yInverted {
sy = srcH - 1 - sy
}
srcOff := sy*srcStride + sx*4
if srcOff+4 <= len(src) {
magColor := Color{B: src[srcOff+0], G: src[srcOff+1], R: src[srcOff+2], A: 255}
@@ -449,7 +467,7 @@ func drawMagnifier(
} else {
finalColor = borderColor
}
} else {
default:
finalColor = borderColor
}
@@ -460,6 +478,9 @@ func drawMagnifier(
sy := cy + int(math.Round(fy))
sx = clamp(sx, 0, srcW-1)
sy = clamp(sy, 0, srcH-1)
if yInverted {
sy = srcH - 1 - sy
}
srcOff := sy*srcStride + sx*4
if srcOff+4 <= len(src) {
finalColor = Color{B: src[srcOff+0], G: src[srcOff+1], R: src[srcOff+2], A: 255}
+5 -7
View File
@@ -435,7 +435,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty")
assert.Contains(t, string(content), "bind = $mod, T, exec, $TERMINAL")
assert.Contains(t, string(content), "exec-once = ")
})
@@ -471,7 +471,7 @@ general {
require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty")
assert.Contains(t, string(newContent), "bind = $mod, T, exec, $TERMINAL")
assert.NotContains(t, string(newContent), "monitor = eDP-2")
})
}
@@ -487,16 +487,14 @@ func TestNiriConfigStructure(t *testing.T) {
func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# ENVIRONMENT VARS")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
assert.Contains(t, HyprlandConfig, "{{POLKIT_AGENT_PATH}}")
assert.Contains(t, HyprlandConfig, "{{TERMINAL_COMMAND}}")
assert.Contains(t, HyprlandConfig, "exec-once = dms run")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec,")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec, $TERMINAL")
assert.Contains(t, HyprlandConfig, "bind = $mod, space, exec, dms ipc call spotlight toggle")
assert.Contains(t, HyprlandConfig, "windowrulev2 = noborder, class:^(com\\.mitchellh\\.ghostty)$")
assert.Contains(t, HyprlandConfig, "windowrule {")
assert.Contains(t, HyprlandConfig, "match:class = ^(com\\.mitchellh\\.ghostty)$")
}
func TestGhosttyConfigStructure(t *testing.T) {
+123 -40
View File
@@ -7,20 +7,10 @@
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# ENVIRONMENT VARS
# ==================
env = QT_QPA_PLATFORM,wayland
env = ELECTRON_OZONE_PLATFORM_HINT,auto
env = QT_QPA_PLATFORMTHEME,gtk3
env = QT_QPA_PLATFORMTHEME_QT6,gtk3
env = TERMINAL,{{TERMINAL_COMMAND}}
# ==================
# STARTUP APPS
# ==================
exec-once = bash -c "wl-paste --watch cliphist store &"
exec-once = dms run
exec-once = {{POLKIT_AGENT_PATH}}
# ==================
@@ -100,36 +90,132 @@ misc {
# ==================
# WINDOW RULES
# ==================
windowrulev2 = tile, class:^(org\.wezfurlong\.wezterm)$
windowrule {
name = windowrule-1
tile = on
match:class = ^(org\.wezfurlong\.wezterm)$
border_size = 0
}
windowrulev2 = rounding 12, class:^(org\.gnome\.)
windowrulev2 = noborder, class:^(org\.gnome\.)
windowrulev2 = tile, class:^(gnome-control-center)$
windowrulev2 = tile, class:^(pavucontrol)$
windowrulev2 = tile, class:^(nm-connection-editor)$
windowrule {
name = windowrule-2
rounding = 12
match:class = ^(org\.gnome\.)
border_size = 0
}
windowrulev2 = float, class:^(gnome-calculator)$
windowrulev2 = float, class:^(galculator)$
windowrulev2 = float, class:^(blueman-manager)$
windowrulev2 = float, class:^(org\.gnome\.Nautilus)$
windowrulev2 = float, class:^(steam)$
windowrulev2 = float, class:^(xdg-desktop-portal)$
windowrulev2 = noborder, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = noborder, class:^(Alacritty)$
windowrulev2 = noborder, class:^(zen)$
windowrulev2 = noborder, class:^(com\.mitchellh\.ghostty)$
windowrulev2 = noborder, class:^(kitty)$
windowrulev2 = float, class:^(firefox)$, title:^(Picture-in-Picture)$
windowrulev2 = float, class:^(zoom)$
windowrule {
name = windowrule-3
tile = on
match:class = ^(gnome-control-center)$
}
# DMS windows floating by default
windowrulev2 = float, class:^(org.quickshell)$
windowrulev2 = opacity 0.9 0.9, floating:0, focus:0
windowrule {
name = windowrule-4
tile = on
match:class = ^(pavucontrol)$
}
layerrule = noanim, ^(quickshell)$
windowrule {
name = windowrule-5
tile = on
match:class = ^(nm-connection-editor)$
}
windowrule {
name = windowrule-6
float = on
match:class = ^(gnome-calculator)$
}
windowrule {
name = windowrule-7
float = on
match:class = ^(galculator)$
}
windowrule {
name = windowrule-8
float = on
match:class = ^(blueman-manager)$
}
windowrule {
name = windowrule-9
float = on
match:class = ^(org\.gnome\.Nautilus)$
}
windowrule {
name = windowrule-10
float = on
match:class = ^(steam)$
}
windowrule {
name = windowrule-11
float = on
match:class = ^(xdg-desktop-portal)$
}
windowrule {
name = windowrule-12
border_size = 0
match:class = ^(Alacritty)$
}
windowrule {
name = windowrule-13
border_size = 0
match:class = ^(zen)$
}
windowrule {
name = windowrule-14
border_size = 0
match:class = ^(com\.mitchellh\.ghostty)$
}
windowrule {
name = windowrule-15
border_size = 0
match:class = ^(kitty)$
}
windowrule {
name = windowrule-16
float = on
match:class = ^(firefox)$
match:title = ^(Picture-in-Picture)$
}
windowrule {
name = windowrule-17
float = on
match:class = ^(zoom)$
}
windowrule {
name = windowrule-18
opacity = 0.9 0.9
match:float = 0
match:focus = 0
}
layerrule {
name = layerrule-1
no_anim = on
match:namespace = ^(quickshell)$
}
# ==================
# KEYBINDINGS
@@ -137,7 +223,7 @@ layerrule = noanim, ^(quickshell)$
$mod = SUPER
# === Application Launchers ===
bind = $mod, T, exec, {{TERMINAL_COMMAND}}
bind = $mod, T, exec, $TERMINAL
bind = $mod, space, exec, dms ipc call spotlight toggle
bind = $mod, V, exec, dms ipc call clipboard toggle
bind = $mod, M, exec, dms ipc call processlist focusOrToggle
@@ -281,12 +367,9 @@ binde = $mod SHIFT, minus, resizeactive, 0 -10%
binde = $mod SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , XF86Launch1, exec, grimblast copy area
bind = CTRL, XF86Launch1, exec, grimblast copy screen
bind = ALT, XF86Launch1, exec, grimblast copy active
bind = , Print, exec, grimblast copy area
bind = CTRL, Print, exec, grimblast copy screen
bind = ALT, Print, exec, grimblast copy active
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = $mod SHIFT, P, dpms, off
-6
View File
@@ -116,15 +116,9 @@ overview {
// See the binds section below for more spawn examples.
// This line starts waybar, a commonly used bar for Wayland compositors.
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
spawn-at-startup "dms" "run"
spawn-at-startup "{{POLKIT_AGENT_PATH}}"
environment {
XDG_CURRENT_DESKTOP "niri"
QT_QPA_PLATFORM "wayland"
ELECTRON_OZONE_PLATFORM_HINT "auto"
QT_QPA_PLATFORMTHEME "gtk3"
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
TERMINAL "{{TERMINAL_COMMAND}}"
}
hotkey-overlay {
skip-at-startup
+81
View File
@@ -23,6 +23,17 @@ type ColorInfo struct {
B int `json:"b"`
}
type VariantColorValue struct {
Hex string `json:"hex"`
HexStripped string `json:"hex_stripped"`
}
type VariantColorInfo struct {
Dark VariantColorValue `json:"dark"`
Light VariantColorValue `json:"light"`
Default VariantColorValue `json:"default"`
}
type Palette struct {
Color0 ColorInfo `json:"color0"`
Color1 ColorInfo `json:"color1"`
@@ -42,6 +53,25 @@ type Palette struct {
Color15 ColorInfo `json:"color15"`
}
type VariantPalette struct {
Color0 VariantColorInfo `json:"color0"`
Color1 VariantColorInfo `json:"color1"`
Color2 VariantColorInfo `json:"color2"`
Color3 VariantColorInfo `json:"color3"`
Color4 VariantColorInfo `json:"color4"`
Color5 VariantColorInfo `json:"color5"`
Color6 VariantColorInfo `json:"color6"`
Color7 VariantColorInfo `json:"color7"`
Color8 VariantColorInfo `json:"color8"`
Color9 VariantColorInfo `json:"color9"`
Color10 VariantColorInfo `json:"color10"`
Color11 VariantColorInfo `json:"color11"`
Color12 VariantColorInfo `json:"color12"`
Color13 VariantColorInfo `json:"color13"`
Color14 VariantColorInfo `json:"color14"`
Color15 VariantColorInfo `json:"color15"`
}
func NewColorInfo(hex string) ColorInfo {
rgb := HexToRGB(hex)
stripped := hex
@@ -492,3 +522,54 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
return palette
}
type VariantOptions struct {
PrimaryDark string
PrimaryLight string
Background string
UseDPS bool
IsLightMode bool
}
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
darkVal := VariantColorValue{Hex: dark.Hex, HexStripped: dark.HexStripped}
lightVal := VariantColorValue{Hex: light.Hex, HexStripped: light.HexStripped}
defaultVal := darkVal
if isLightMode {
defaultVal = lightVal
}
return VariantColorInfo{
Dark: darkVal,
Light: lightVal,
Default: defaultVal,
}
}
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS}
lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS}
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
light := GeneratePalette(opts.PrimaryLight, lightOpts)
return VariantPalette{
Color0: mergeColorInfo(dark.Color0, light.Color0, opts.IsLightMode),
Color1: mergeColorInfo(dark.Color1, light.Color1, opts.IsLightMode),
Color2: mergeColorInfo(dark.Color2, light.Color2, opts.IsLightMode),
Color3: mergeColorInfo(dark.Color3, light.Color3, opts.IsLightMode),
Color4: mergeColorInfo(dark.Color4, light.Color4, opts.IsLightMode),
Color5: mergeColorInfo(dark.Color5, light.Color5, opts.IsLightMode),
Color6: mergeColorInfo(dark.Color6, light.Color6, opts.IsLightMode),
Color7: mergeColorInfo(dark.Color7, light.Color7, opts.IsLightMode),
Color8: mergeColorInfo(dark.Color8, light.Color8, opts.IsLightMode),
Color9: mergeColorInfo(dark.Color9, light.Color9, opts.IsLightMode),
Color10: mergeColorInfo(dark.Color10, light.Color10, opts.IsLightMode),
Color11: mergeColorInfo(dark.Color11, light.Color11, opts.IsLightMode),
Color12: mergeColorInfo(dark.Color12, light.Color12, opts.IsLightMode),
Color13: mergeColorInfo(dark.Color13, light.Color13, opts.IsLightMode),
Color14: mergeColorInfo(dark.Color14, light.Color14, opts.IsLightMode),
Color15: mergeColorInfo(dark.Color15, light.Color15, opts.IsLightMode),
}
}
+5
View File
@@ -11,6 +11,11 @@ func GenerateJSON(p Palette) string {
return string(marshalled)
}
func GenerateVariantJSON(p VariantPalette) string {
marshalled, _ := json.Marshal(p)
return string(marshalled)
}
func GenerateKittyTheme(p Palette) string {
var result strings.Builder
fmt.Fprintf(&result, "color0 %s\n", p.Color0.Hex)
+9 -21
View File
@@ -91,7 +91,6 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectWindowManager(wm))
dependencies = append(dependencies, a.detectQuickshell())
dependencies = append(dependencies, a.detectXDGPortal())
dependencies = append(dependencies, a.detectPolkitAgent())
dependencies = append(dependencies, a.detectAccountsService())
// Hyprland-specific tools
@@ -107,7 +106,6 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
// Base detections (common across distros)
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
dependencies = append(dependencies, a.detectHyprpicker())
dependencies = append(dependencies, a.detectClipboardTools()...)
return dependencies, nil
@@ -127,20 +125,6 @@ func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
}
}
func (a *ArchDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (a *ArchDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("accountsservice") {
@@ -178,18 +162,13 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeSystem},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = a.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = a.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"])
@@ -378,6 +357,15 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
LogOutput: "Starting post-installation configuration...",
}
terminal := a.DetectTerminalFromDeps(dependencies)
if err := a.WriteEnvironmentConfig(terminal); err != nil {
a.log(fmt.Sprintf("Warning: failed to write environment config: %v", err))
}
if err := a.EnableDMSService(ctx); err != nil {
a.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err))
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
+67 -20
View File
@@ -17,8 +17,10 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
)
const forceQuickshellGit = false
const forceDMSGit = false
const (
forceQuickshellGit = false
forceDMSGit = false
)
// BaseDistribution provides common functionality for all distributions
type BaseDistribution struct {
@@ -219,20 +221,6 @@ func (b *BaseDistribution) detectClipboardTools() []deps.Dependency {
return dependencies
}
func (b *BaseDistribution) detectHyprpicker() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("hyprpicker") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "hyprpicker",
Status: status,
Description: "Color picker for Wayland",
Required: true,
}
}
func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency {
var dependencies []deps.Dependency
@@ -240,10 +228,7 @@ func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency {
name string
description string
}{
{"grim", "Screenshot utility for Wayland"},
{"slurp", "Region selection utility for Wayland"},
{"hyprctl", "Hyprland control utility"},
{"grimblast", "Screenshot script for Hyprland"},
{"jq", "JSON processor"},
}
@@ -564,6 +549,68 @@ func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressCha
}
}
func (b *BaseDistribution) DetectTerminalFromDeps(dependencies []deps.Dependency) deps.Terminal {
for _, dep := range dependencies {
switch dep.Name {
case "ghostty":
return deps.TerminalGhostty
case "kitty":
return deps.TerminalKitty
case "alacritty":
return deps.TerminalAlacritty
}
}
return deps.TerminalGhostty
}
func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
envDir := filepath.Join(homeDir, ".config", "environment.d")
if err := os.MkdirAll(envDir, 0755); err != nil {
return fmt.Errorf("failed to create environment.d directory: %w", err)
}
var terminalCmd string
switch terminal {
case deps.TerminalGhostty:
terminalCmd = "ghostty"
case deps.TerminalKitty:
terminalCmd = "kitty"
case deps.TerminalAlacritty:
terminalCmd = "alacritty"
default:
terminalCmd = "ghostty"
}
content := fmt.Sprintf(`QT_QPA_PLATFORM=wayland
ELECTRON_OZONE_PLATFORM_HINT=auto
QT_QPA_PLATFORMTHEME=gtk3
QT_QPA_PLATFORMTHEME_QT6=gtk3
TERMINAL=%s
`, terminalCmd)
envFile := filepath.Join(envDir, "90-dms.conf")
if err := os.WriteFile(envFile, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write environment config: %w", err)
}
b.log(fmt.Sprintf("Wrote environment config to %s", envFile))
return nil
}
func (b *BaseDistribution) EnableDMSService(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "systemctl", "--user", "enable", "--now", "dms")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to enable dms service: %w", err)
}
b.log("Enabled dms systemd user service")
return nil
}
// installDMSBinary installs the DMS binary from GitHub releases
func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
b.log("Installing/updating DMS binary...")
@@ -602,7 +649,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
return fmt.Errorf("failed to get user home directory: %w", err)
}
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
+10 -47
View File
@@ -61,7 +61,6 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, d.detectWindowManager(wm))
dependencies = append(dependencies, d.detectQuickshell())
dependencies = append(dependencies, d.detectXDGPortal())
dependencies = append(dependencies, d.detectPolkitAgent())
dependencies = append(dependencies, d.detectAccountsService())
if wm == deps.WindowManagerNiri {
@@ -89,20 +88,6 @@ func (d *DebianDistribution) detectXDGPortal() deps.Dependency {
}
}
func (d *DebianDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (d *DebianDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if d.commandExists("xwayland-satellite") {
@@ -149,7 +134,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
// DMS packages from OBS with variant support
@@ -158,9 +142,7 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"cliphist": {Name: "cliphist", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
// Keep ghostty as manual (no OBS package yet)
"ghostty": {Name: "ghostty", Repository: RepoTypeManual, BuildFunc: "installGhostty"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
}
if wm == deps.WindowManagerNiri {
@@ -351,6 +333,15 @@ func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies [
LogOutput: "Starting post-installation configuration...",
}
terminal := d.DetectTerminalFromDeps(dependencies)
if err := d.WriteEnvironmentConfig(terminal); err != nil {
d.log(fmt.Sprintf("Warning: failed to write environment config: %v", err))
}
if err := d.EnableDMSService(ctx); err != nil {
d.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err))
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
@@ -664,30 +655,6 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}
func (d *DebianDistribution) installGhosttyDebian(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
d.log("Installing Ghostty using Debian installer script...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Running Ghostty Debian installer...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | sudo bash",
LogOutput: "Installing Ghostty using pre-built Debian package",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
"/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)\"")
if err := d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.9); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
d.log("Ghostty installed successfully using Debian installer")
return nil
}
func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
@@ -697,10 +664,6 @@ func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages
for _, pkg := range packages {
switch pkg {
case "ghostty":
if err := d.installGhosttyDebian(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
default:
if err := d.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
+9 -28
View File
@@ -76,7 +76,6 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectWindowManager(wm))
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectXDGPortal())
dependencies = append(dependencies, f.detectPolkitAgent())
dependencies = append(dependencies, f.detectAccountsService())
// Hyprland-specific tools
@@ -92,7 +91,6 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
// Base detections (common across distros)
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectHyprpicker())
dependencies = append(dependencies, f.detectClipboardTools()...)
return dependencies, nil
@@ -112,20 +110,6 @@ func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
}
}
func (f *FedoraDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
@@ -145,9 +129,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": f.getHyprpickerMapping(variants["hyprland"]),
// COPR packages
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
@@ -160,10 +142,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = f.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = f.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"])
@@ -194,13 +173,6 @@ func (f *FedoraDistribution) getHyprlandMapping(variant deps.PackageVariant) Pac
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
func (f *FedoraDistribution) getHyprpickerMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprpicker-git", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
return PackageMapping{Name: "hyprpicker", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri-git"}
@@ -385,6 +357,15 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
LogOutput: "Starting post-installation configuration...",
}
terminal := f.DetectTerminalFromDeps(dependencies)
if err := f.WriteEnvironmentConfig(terminal); err != nil {
f.log(fmt.Sprintf("Warning: failed to write environment config: %v", err))
}
if err := f.EnableDMSService(ctx); err != nil {
f.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err))
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
+9 -9
View File
@@ -108,7 +108,6 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectHyprpicker())
dependencies = append(dependencies, g.detectClipboardTools()...)
return dependencies, nil
@@ -190,7 +189,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"xdg-desktop-portal-gtk": {Name: "sys-apps/xdg-desktop-portal-gtk", Repository: RepoTypeSystem, UseFlags: "wayland X"},
"mate-polkit": {Name: "mate-extra/mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "sys-apps/accountsservice", Repository: RepoTypeSystem},
"hyprpicker": g.getHyprpickerMapping(variants["hyprland"]),
"qtbase": {Name: "dev-qt/qtbase", Repository: RepoTypeSystem, UseFlags: "wayland opengl vulkan widgets"},
"qtdeclarative": {Name: "dev-qt/qtdeclarative", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"},
@@ -207,10 +205,7 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = g.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "gui-apps/grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "gui-apps/slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = g.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "gui-wm/hyprland-contrib", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["jq"] = PackageMapping{Name: "app-misc/jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"])
@@ -236,10 +231,6 @@ func (g *GentooDistribution) getHyprlandMapping(variant deps.PackageVariant) Pac
return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeSystem, UseFlags: "X", AcceptKeywords: archKeyword}
}
func (g *GentooDistribution) getHyprpickerMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-apps/hyprpicker", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
}
@@ -460,6 +451,15 @@ func (g *GentooDistribution) InstallPackages(ctx context.Context, dependencies [
LogOutput: "Starting post-installation configuration...",
}
terminal := g.DetectTerminalFromDeps(dependencies)
if err := g.WriteEnvironmentConfig(terminal); err != nil {
g.log(fmt.Sprintf("Warning: failed to write environment config: %v", err))
}
if err := g.EnableDMSService(ctx); err != nil {
g.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err))
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
-60
View File
@@ -62,10 +62,6 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
if err := m.installDgop(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install dgop: %w", err)
}
case "grimblast":
if err := m.installGrimblast(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install grimblast: %w", err)
}
case "niri":
if err := m.installNiri(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install niri: %w", err)
@@ -166,62 +162,6 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s
return nil
}
func (m *ManualPackageInstaller) installGrimblast(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing grimblast script for Hyprland...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Downloading grimblast script...",
IsComplete: false,
CommandInfo: "curl grimblast script",
}
grimblastURL := "https://raw.githubusercontent.com/hyprwm/contrib/refs/heads/main/grimblast/grimblast"
tmpPath := filepath.Join(os.TempDir(), "grimblast")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", "-o", tmpPath, grimblastURL)
if err := downloadCmd.Run(); err != nil {
m.logError("failed to download grimblast", err)
return fmt.Errorf("failed to download grimblast: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.5,
Step: "Making grimblast executable...",
IsComplete: false,
CommandInfo: "chmod +x grimblast",
}
chmodCmd := exec.CommandContext(ctx, "chmod", "+x", tmpPath)
if err := chmodCmd.Run(); err != nil {
m.logError("failed to make grimblast executable", err)
return fmt.Errorf("failed to make grimblast executable: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing grimblast to /usr/local/bin...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp grimblast /usr/local/bin/",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/grimblast", tmpPath))
if err := installCmd.Run(); err != nil {
m.logError("failed to install grimblast", err)
return fmt.Errorf("failed to install grimblast: %w", err)
}
os.Remove(tmpPath)
m.log("grimblast installed successfully to /usr/local/bin")
return nil
}
func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing niri from source...")
+9 -19
View File
@@ -66,7 +66,6 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
dependencies = append(dependencies, o.detectWindowManager(wm))
dependencies = append(dependencies, o.detectQuickshell())
dependencies = append(dependencies, o.detectXDGPortal())
dependencies = append(dependencies, o.detectPolkitAgent())
dependencies = append(dependencies, o.detectAccountsService())
// Hyprland-specific tools
@@ -101,20 +100,6 @@ func (o *OpenSUSEDistribution) detectXDGPortal() deps.Dependency {
}
}
func (o *OpenSUSEDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
@@ -134,7 +119,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
@@ -148,10 +132,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
// Niri stable has native package support on openSUSE
@@ -391,6 +372,15 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
LogOutput: "Starting post-installation configuration...",
}
terminal := o.DetectTerminalFromDeps(dependencies)
if err := o.WriteEnvironmentConfig(terminal); err != nil {
o.log(fmt.Sprintf("Warning: failed to write environment config: %v", err))
}
if err := o.EnableDMSService(ctx); err != nil {
o.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err))
}
// Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
+10 -94
View File
@@ -3,9 +3,7 @@ package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
@@ -66,7 +64,6 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, u.detectWindowManager(wm))
dependencies = append(dependencies, u.detectQuickshell())
dependencies = append(dependencies, u.detectXDGPortal())
dependencies = append(dependencies, u.detectPolkitAgent())
dependencies = append(dependencies, u.detectAccountsService())
// Hyprland-specific tools
@@ -101,20 +98,6 @@ func (u *UbuntuDistribution) detectXDGPortal() deps.Dependency {
}
}
func (u *UbuntuDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if u.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (u *UbuntuDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if u.commandExists("xwayland-satellite") {
@@ -161,7 +144,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
// DMS packages from PPAs
@@ -170,19 +152,14 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"cliphist": {Name: "cliphist", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
// Keep ghostty as manual (no PPA available)
"ghostty": {Name: "ghostty", Repository: RepoTypeManual, BuildFunc: "installGhostty"},
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
}
switch wm {
case deps.WindowManagerHyprland:
// Use the cppiber PPA for Hyprland
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
niriVariant := variants["niri"]
@@ -375,6 +352,15 @@ func (u *UbuntuDistribution) InstallPackages(ctx context.Context, dependencies [
LogOutput: "Starting post-installation configuration...",
}
terminal := u.DetectTerminalFromDeps(dependencies)
if err := u.WriteEnvironmentConfig(terminal); err != nil {
u.log(fmt.Sprintf("Warning: failed to write environment config: %v", err))
}
if err := u.EnableDMSService(ctx); err != nil {
u.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err))
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
@@ -577,10 +563,6 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
buildDeps["libxcb1-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libpam0g-dev"] = true
case "ghostty":
buildDeps["curl"] = true
buildDeps["libgtk-4-dev"] = true
buildDeps["libadwaita-1-dev"] = true
case "matugen":
buildDeps["curl"] = true
case "cliphist":
@@ -594,10 +576,6 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "ghostty":
if err := u.installZig(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Zig: %w", err)
}
case "cliphist", "dgop":
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
@@ -661,40 +639,6 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
return nil
}
func (u *UbuntuDistribution) installZig(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("zig") {
return nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
zigUrl := "https://ziglang.org/download/0.11.0/zig-linux-x86_64-0.11.0.tar.xz"
zigTmp := filepath.Join(cacheDir, "zig.tar.xz")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", zigUrl, "-o", zigTmp)
if err := u.runWithProgress(downloadCmd, progressChan, PhaseSystemPackages, 0.84, 0.85); err != nil {
return fmt.Errorf("failed to download Zig: %w", err)
}
extractCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("tar -xf %s -C /opt/", zigTmp))
if err := u.runWithProgress(extractCmd, progressChan, PhaseSystemPackages, 0.85, 0.86); err != nil {
return fmt.Errorf("failed to extract Zig: %w", err)
}
linkCmd := ExecSudoCommand(ctx, sudoPassword,
"ln -sf /opt/zig-linux-x86_64-0.11.0/zig /usr/local/bin/zig")
return u.runWithProgress(linkCmd, progressChan, PhaseSystemPackages, 0.86, 0.87)
}
func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("go") {
return nil
@@ -742,30 +686,6 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
}
func (u *UbuntuDistribution) installGhosttyUbuntu(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
u.log("Installing Ghostty using Ubuntu installer script...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Running Ghostty Ubuntu installer...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | sudo bash",
LogOutput: "Installing Ghostty using pre-built Ubuntu package",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
"/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)\"")
if err := u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.9); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
u.log("Ghostty installed successfully using Ubuntu installer")
return nil
}
func (u *UbuntuDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
@@ -775,10 +695,6 @@ func (u *UbuntuDistribution) InstallManualPackages(ctx context.Context, packages
for _, pkg := range packages {
switch pkg {
case "ghostty":
if err := u.installGhosttyUbuntu(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
default:
if err := u.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
+1 -1
View File
@@ -514,7 +514,7 @@ func (m Model) categorizeDependencies() map[string][]DependencyInfo {
switch dep.Name {
case "dms (DankMaterialShell)", "quickshell":
categories["Shell"] = append(categories["Shell"], dep)
case "hyprland", "grim", "slurp", "hyprctl", "grimblast":
case "hyprland", "hyprctl":
categories["Hyprland Components"] = append(categories["Hyprland Components"], dep)
case "niri":
categories["Niri Components"] = append(categories["Niri Components"], dep)
+21 -3
View File
@@ -371,6 +371,18 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
node.SetName(parts[0])
for _, arg := range parts[1:] {
if strings.Contains(arg, "=") {
kv := strings.SplitN(arg, "=", 2)
switch kv[1] {
case "true":
node.AddProperty(kv[0], true, "")
case "false":
node.AddProperty(kv[0], false, "")
default:
node.AddProperty(kv[0], kv[1], "")
}
continue
}
node.AddArgument(arg, "")
}
return node
@@ -379,7 +391,7 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
func (n *NiriProvider) parseActionParts(action string) []string {
var parts []string
var current strings.Builder
var inQuote, escaped bool
var inQuote, escaped, wasQuoted bool
for _, r := range action {
switch {
@@ -389,17 +401,19 @@ func (n *NiriProvider) parseActionParts(action string) []string {
case r == '\\':
escaped = true
case r == '"':
wasQuoted = true
inQuote = !inQuote
case r == ' ' && !inQuote:
if current.Len() > 0 {
if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String())
current.Reset()
wasQuoted = false
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String())
}
return parts
@@ -508,6 +522,10 @@ func (n *NiriProvider) writeBindNode(sb *strings.Builder, bind *overrideBind, in
sb.WriteString(" ")
n.writeArg(sb, arg.ValueString(), forceQuote)
}
if child.Properties.Exist() {
sb.WriteString(" ")
sb.WriteString(strings.TrimLeft(child.Properties.String(), " "))
}
}
sb.WriteString("; }\n")
}
@@ -265,6 +265,11 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
for _, arg := range actionNode.Arguments {
args = append(args, arg.ValueString())
}
if actionNode.Properties != nil {
if val, ok := actionNode.Properties.Get("focus"); ok {
args = append(args, "focus="+val.String())
}
}
}
var description string
@@ -602,8 +602,24 @@ func TestNiriParseActionWithProperties(t *testing.T) {
for _, kb := range result.Section.Keybinds {
switch kb.Action {
case "move-column-to-workspace":
if len(kb.Args) != 1 {
t.Errorf("move-column-to-workspace should have 1 arg, got %d", len(kb.Args))
if len(kb.Args) != 2 {
t.Errorf("move-column-to-workspace should have 2 args (index + focus), got %d", len(kb.Args))
}
hasIndex := false
hasFocus := false
for _, arg := range kb.Args {
if arg == "1" || arg == "2" {
hasIndex = true
}
if arg == "focus=false" {
hasFocus = true
}
}
if !hasIndex {
t.Errorf("move-column-to-workspace missing index arg")
}
if !hasFocus {
t.Errorf("move-column-to-workspace missing focus=false arg")
}
case "next-window":
if kb.Key != "Tab" {
+587
View File
@@ -0,0 +1,587 @@
package matugen
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
var (
matugenVersionOnce sync.Once
matugenSupportsCOE bool
)
type Options struct {
StateDir string
ShellDir string
ConfigDir string
Kind string
Value string
Mode string
IconTheme string
MatugenType string
RunUserTemplates bool
StockColors string
SyncModeWithPortal bool
TerminalsAlwaysDark bool
}
type ColorsOutput struct {
Colors struct {
Dark map[string]string `json:"dark"`
Light map[string]string `json:"light"`
} `json:"colors"`
}
func (o *Options) ColorsOutput() string {
return filepath.Join(o.StateDir, "dms-colors.json")
}
func Run(opts Options) error {
if opts.StateDir == "" {
return fmt.Errorf("state-dir is required")
}
if opts.ShellDir == "" {
return fmt.Errorf("shell-dir is required")
}
if opts.ConfigDir == "" {
return fmt.Errorf("config-dir is required")
}
if opts.Kind == "" {
return fmt.Errorf("kind is required")
}
if opts.Value == "" {
return fmt.Errorf("value is required")
}
if opts.Mode == "" {
opts.Mode = "dark"
}
if opts.MatugenType == "" {
opts.MatugenType = "scheme-tonal-spot"
}
if opts.IconTheme == "" {
opts.IconTheme = "System Default"
}
if err := os.MkdirAll(opts.StateDir, 0755); err != nil {
return fmt.Errorf("failed to create state dir: %w", err)
}
log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
if err := buildOnce(&opts); err != nil {
return err
}
if opts.SyncModeWithPortal {
syncColorScheme(opts.Mode)
}
log.Info("Done")
return nil
}
func buildOnce(opts *Options) error {
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp config: %w", err)
}
defer os.Remove(cfgFile.Name())
defer cfgFile.Close()
tmpDir, err := os.MkdirTemp("", "matugen-templates-*")
if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := buildMergedConfig(opts, cfgFile, tmpDir); err != nil {
return fmt.Errorf("failed to build config: %w", err)
}
cfgFile.Close()
var primaryDark, primaryLight, surface string
var dank16JSON string
var importArgs []string
if opts.StockColors != "" {
log.Info("Using stock/custom theme colors with matugen base")
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
surface = extractNestedColor(opts.StockColors, "surface", "dark")
if primaryDark == "" {
return fmt.Errorf("failed to extract primary dark from stock colors")
}
if primaryLight == "" {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
importArgs = []string{"--import-json-string", importData}
log.Info("Running matugen color hex with stock color overrides")
args := []string{"color", "hex", primaryDark, "-m", opts.Mode, "-t", opts.MatugenType, "-c", cfgFile.Name()}
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return err
}
} else {
log.Infof("Using dynamic theme from %s: %s", opts.Kind, opts.Value)
matJSON, err := runMatugenDryRun(opts)
if err != nil {
return fmt.Errorf("matugen dry-run failed: %w", err)
}
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
primaryLight = extractMatugenColor(matJSON, "primary", "light")
surface = extractMatugenColor(matJSON, "surface", "dark")
if primaryDark == "" {
return fmt.Errorf("failed to extract primary color")
}
if primaryLight == "" {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
importData := fmt.Sprintf(`{"dank16": %s}`, dank16JSON)
importArgs = []string{"--import-json-string", importData}
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
var args []string
switch opts.Kind {
case "hex":
args = []string{"color", "hex", opts.Value}
default:
args = []string{opts.Kind, opts.Value}
}
args = append(args, "-m", opts.Mode, "-t", opts.MatugenType, "-c", cfgFile.Name())
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return err
}
}
refreshGTK(opts.ConfigDir, opts.Mode)
signalTerminals()
return nil
}
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
wroteConfig := false
if opts.RunUserTemplates {
if data, err := os.ReadFile(userConfigPath); err == nil {
configSection := extractTOMLSection(string(data), "[config]", "[templates]")
if configSection != "" {
cfgFile.WriteString(configSection)
cfgFile.WriteString("\n")
wroteConfig = true
}
}
}
if !wroteConfig {
cfgFile.WriteString("[config]\n\n")
}
baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
if data, err := os.ReadFile(baseConfigPath); err == nil {
content := string(data)
lines := strings.Split(content, "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "[config]" {
continue
}
cfgFile.WriteString(substituteShellDir(line, opts.ShellDir) + "\n")
}
cfgFile.WriteString("\n")
}
fmt.Fprintf(cfgFile, `[templates.dank]
input_path = '%s/matugen/templates/dank.json'
output_path = '%s'
`, opts.ShellDir, opts.ColorsOutput())
switch opts.Mode {
case "light":
appendConfig(opts, cfgFile, "skip", "gtk3-light.toml")
default:
appendConfig(opts, cfgFile, "skip", "gtk3-dark.toml")
}
appendConfig(opts, cfgFile, "niri", "niri.toml")
appendConfig(opts, cfgFile, "qt5ct", "qt5ct.toml")
appendConfig(opts, cfgFile, "qt6ct", "qt6ct.toml")
appendConfig(opts, cfgFile, "firefox", "firefox.toml")
appendConfig(opts, cfgFile, "pywalfox", "pywalfox.toml")
appendConfig(opts, cfgFile, "vesktop", "vesktop.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, "ghostty", "ghostty.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, "kitty", "kitty.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, "foot", "foot.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, "alacritty", "alacritty.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, "wezterm", "wezterm.toml")
appendConfig(opts, cfgFile, "dgop", "dgop.toml")
homeDir, _ := os.UserHomeDir()
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
if opts.RunUserTemplates {
if data, err := os.ReadFile(userConfigPath); err == nil {
templatesSection := extractTOMLSection(string(data), "[templates]", "")
if templatesSection != "" {
cfgFile.WriteString(templatesSection)
cfgFile.WriteString("\n")
}
}
}
userPluginConfigDir := filepath.Join(opts.ConfigDir, "matugen", "dms", "configs")
if entries, err := os.ReadDir(userPluginConfigDir); err == nil {
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".toml") {
continue
}
if data, err := os.ReadFile(filepath.Join(userPluginConfigDir, entry.Name())); err == nil {
cfgFile.WriteString(string(data))
cfgFile.WriteString("\n")
}
}
}
return nil
}
func appendConfig(opts *Options, cfgFile *os.File, checkCmd, fileName string) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil {
return
}
if checkCmd != "skip" && !commandExists(checkCmd) {
return
}
data, err := os.ReadFile(configPath)
if err != nil {
return
}
cfgFile.WriteString(substituteShellDir(string(data), opts.ShellDir))
cfgFile.WriteString("\n")
}
func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir, checkCmd, fileName string) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil {
return
}
if checkCmd != "skip" && !commandExists(checkCmd) {
return
}
data, err := os.ReadFile(configPath)
if err != nil {
return
}
content := string(data)
if !opts.TerminalsAlwaysDark {
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
cfgFile.WriteString("\n")
return
}
lines := strings.Split(content, "\n")
for _, line := range lines {
if !strings.Contains(line, "input_path") || !strings.Contains(line, "SHELL_DIR/matugen/templates/") {
continue
}
start := strings.Index(line, "'SHELL_DIR/matugen/templates/")
if start == -1 {
continue
}
end := strings.Index(line[start+1:], "'")
if end == -1 {
continue
}
templateName := line[start+len("'SHELL_DIR/matugen/templates/") : start+1+end]
origPath := filepath.Join(opts.ShellDir, "matugen", "templates", templateName)
origData, err := os.ReadFile(origPath)
if err != nil {
continue
}
modified := strings.ReplaceAll(string(origData), ".default.", ".dark.")
tmpPath := filepath.Join(tmpDir, templateName)
if err := os.WriteFile(tmpPath, []byte(modified), 0644); err != nil {
continue
}
content = strings.ReplaceAll(content,
fmt.Sprintf("'SHELL_DIR/matugen/templates/%s'", templateName),
fmt.Sprintf("'%s'", tmpPath))
}
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
cfgFile.WriteString("\n")
}
func appendVSCodeConfig(cfgFile *os.File, name, extDir, shellDir string) {
if _, err := os.Stat(extDir); err != nil {
return
}
templateDir := filepath.Join(shellDir, "matugen", "templates")
fmt.Fprintf(cfgFile, `[templates.dms%sdefault]
input_path = '%s/vscode-color-theme-default.json'
output_path = '%s/themes/dankshell-default.json'
[templates.dms%sdark]
input_path = '%s/vscode-color-theme-dark.json'
output_path = '%s/themes/dankshell-dark.json'
[templates.dms%slight]
input_path = '%s/vscode-color-theme-light.json'
output_path = '%s/themes/dankshell-light.json'
`, name, templateDir, extDir,
name, templateDir, extDir,
name, templateDir, extDir)
log.Infof("Added %s theme config (extension found at %s)", name, extDir)
}
func substituteShellDir(content, shellDir string) string {
return strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
}
func extractTOMLSection(content, startMarker, endMarker string) string {
startIdx := strings.Index(content, startMarker)
if startIdx == -1 {
return ""
}
if endMarker == "" {
return content[startIdx:]
}
endIdx := strings.Index(content[startIdx:], endMarker)
if endIdx == -1 {
return content[startIdx:]
}
return content[startIdx : startIdx+endIdx]
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func checkMatugenVersion() {
matugenVersionOnce.Do(func() {
cmd := exec.Command("matugen", "--version")
output, err := cmd.Output()
if err != nil {
return
}
versionStr := strings.TrimSpace(string(output))
versionStr = strings.TrimPrefix(versionStr, "matugen ")
parts := strings.Split(versionStr, ".")
if len(parts) < 2 {
return
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return
}
matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1)
if matugenSupportsCOE {
log.Infof("Matugen %s supports --continue-on-error", versionStr)
}
})
}
func runMatugen(args []string) error {
checkMatugenVersion()
if matugenSupportsCOE {
args = append([]string{"--continue-on-error"}, args...)
}
cmd := exec.Command("matugen", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func runMatugenDryRun(opts *Options) (string, error) {
var args []string
switch opts.Kind {
case "hex":
args = []string{"color", "hex", opts.Value}
default:
args = []string{opts.Kind, opts.Value}
}
args = append(args, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
cmd := exec.Command("matugen", args...)
output, err := cmd.Output()
if err != nil {
return "", err
}
return strings.ReplaceAll(string(output), "\n", ""), nil
}
func extractMatugenColor(jsonStr, colorName, variant string) string {
var data map[string]any
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return ""
}
colors, ok := data["colors"].(map[string]any)
if !ok {
return ""
}
colorData, ok := colors[colorName].(map[string]any)
if !ok {
return ""
}
variantData, ok := colorData[variant].(string)
if !ok {
return ""
}
return variantData
}
func extractNestedColor(jsonStr, colorName, variant string) string {
var data map[string]any
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return ""
}
colorData, ok := data[colorName].(map[string]any)
if !ok {
return ""
}
variantData, ok := colorData[variant].(map[string]any)
if !ok {
return ""
}
color, ok := variantData["color"].(string)
if !ok {
return ""
}
return color
}
func generateDank16Variants(primaryDark, primaryLight, surface, mode string) string {
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: surface,
UseDPS: true,
IsLightMode: mode == "light",
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors)
}
func refreshGTK(configDir, mode string) {
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
info, err := os.Lstat(gtkCSS)
if err != nil {
return
}
shouldRun := false
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(gtkCSS)
if err == nil && strings.Contains(target, "dank-colors.css") {
shouldRun = true
}
} else {
data, err := os.ReadFile(gtkCSS)
if err == nil && strings.Contains(string(data), "dank-colors.css") {
shouldRun = true
}
}
if !shouldRun {
return
}
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run()
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "adw-gtk3-"+mode).Run()
}
func signalTerminals() {
signalByName("kitty", syscall.SIGUSR1)
signalByName("ghostty", syscall.SIGUSR2)
signalByName(".kitty-wrapped", syscall.SIGUSR1)
signalByName(".ghostty-wrappe", syscall.SIGUSR2)
}
func signalByName(name string, sig syscall.Signal) {
entries, err := os.ReadDir("/proc")
if err != nil {
return
}
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
comm, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "comm"))
if err != nil {
continue
}
if strings.TrimSpace(string(comm)) == name {
syscall.Kill(pid, sig)
}
}
}
func syncColorScheme(mode string) {
scheme := "prefer-dark"
if mode == "light" {
scheme = "default"
}
if err := exec.Command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme).Run(); err != nil {
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
}
}
+139
View File
@@ -0,0 +1,139 @@
package matugen
import (
"context"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
type Result struct {
Success bool
Error error
}
type QueuedJob struct {
Options Options
Done chan Result
Ctx context.Context
Cancel context.CancelFunc
}
type Queue struct {
mu sync.Mutex
current *QueuedJob
pending *QueuedJob
jobDone chan struct{}
}
var globalQueue *Queue
var queueOnce sync.Once
func GetQueue() *Queue {
queueOnce.Do(func() {
globalQueue = &Queue{
jobDone: make(chan struct{}, 1),
}
})
return globalQueue
}
func (q *Queue) Submit(opts Options) <-chan Result {
result := make(chan Result, 1)
ctx, cancel := context.WithCancel(context.Background())
job := &QueuedJob{
Options: opts,
Done: result,
Ctx: ctx,
Cancel: cancel,
}
q.mu.Lock()
if q.pending != nil {
log.Info("Cancelling pending theme request")
q.pending.Cancel()
q.pending.Done <- Result{Success: false, Error: context.Canceled}
close(q.pending.Done)
}
if q.current != nil {
q.pending = job
q.mu.Unlock()
log.Info("Theme request queued (worker running)")
return result
}
q.current = job
q.mu.Unlock()
go q.runWorker()
return result
}
func (q *Queue) runWorker() {
for {
q.mu.Lock()
job := q.current
if job == nil {
q.mu.Unlock()
return
}
q.mu.Unlock()
select {
case <-job.Ctx.Done():
q.finishJob(Result{Success: false, Error: context.Canceled})
continue
default:
}
log.Infof("Processing theme: %s %s (%s)", job.Options.Kind, job.Options.Value, job.Options.Mode)
err := Run(job.Options)
var result Result
if err != nil {
result = Result{Success: false, Error: err}
} else {
result = Result{Success: true}
}
q.finishJob(result)
}
}
func (q *Queue) finishJob(result Result) {
q.mu.Lock()
defer q.mu.Unlock()
if q.current != nil {
select {
case q.current.Done <- result:
default:
}
close(q.current.Done)
}
q.current = q.pending
q.pending = nil
if q.current == nil {
select {
case q.jobDone <- struct{}{}:
default:
}
}
}
func (q *Queue) IsRunning() bool {
q.mu.Lock()
defer q.mu.Unlock()
return q.current != nil
}
func (q *Queue) HasPending() bool {
q.mu.Lock()
defer q.mu.Unlock()
return q.pending != nil
}
+2 -2
View File
@@ -44,7 +44,7 @@ func NewZdwlIpcManagerV2(ctx *client.Context) *ZdwlIpcManagerV2 {
// Indicates that the client will not the dwl_ipc_manager object anymore.
// Objects created through this instance are not affected.
func (i *ZdwlIpcManagerV2) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -188,7 +188,7 @@ func NewZdwlIpcOutputV2(ctx *client.Context) *ZdwlIpcOutputV2 {
//
// Indicates to that the client no longer needs this dwl_ipc_output.
func (i *ZdwlIpcOutputV2) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -174,7 +174,7 @@ func (i *ExtWorkspaceManagerV1) Stop() error {
}
func (i *ExtWorkspaceManagerV1) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -385,7 +385,7 @@ func (i *ExtWorkspaceGroupHandleV1) CreateWorkspace(workspace string) error {
// use the workspace group object any more or after the removed event to finalize
// the destruction of the object.
func (i *ExtWorkspaceGroupHandleV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -655,7 +655,7 @@ func NewExtWorkspaceHandleV1(ctx *client.Context) *ExtWorkspaceHandleV1 {
// use the workspace object any more or after the remove event to finalize
// the destruction of the object.
func (i *ExtWorkspaceHandleV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -54,7 +54,7 @@ func NewZwpKeyboardShortcutsInhibitManagerV1(ctx *client.Context) *ZwpKeyboardSh
//
// Destroy the keyboard shortcuts inhibitor manager.
func (i *ZwpKeyboardShortcutsInhibitManagerV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -218,7 +218,7 @@ func NewZwpKeyboardShortcutsInhibitorV1(ctx *client.Context) *ZwpKeyboardShortcu
//
// Remove the keyboard shortcuts inhibitor from the associated wl_surface.
func (i *ZwpKeyboardShortcutsInhibitorV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -85,7 +85,7 @@ func (i *ZwlrGammaControlManagerV1) GetGammaControl(output *client.Output) (*Zwl
// All objects created by the manager will still remain valid, until their
// appropriate destroy request has been called.
func (i *ZwlrGammaControlManagerV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -169,7 +169,7 @@ func (i *ZwlrGammaControlV1) SetGamma(fd int) error {
// Destroys the gamma control object. If the object is still valid, this
// restores the original gamma tables.
func (i *ZwlrGammaControlV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -129,7 +129,7 @@ func (i *ZwlrLayerShellV1) GetLayerSurface(surface *client.Surface, output *clie
// object any more. Objects that have been created through this instance
// are not affected.
func (i *ZwlrLayerShellV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -509,7 +509,7 @@ func (i *ZwlrLayerSurfaceV1) AckConfigure(serial uint32) error {
//
// This request destroys the layer surface.
func (i *ZwlrLayerSurfaceV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 7
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -172,7 +172,7 @@ func (i *ZwlrOutputManagerV1) Stop() error {
}
func (i *ZwlrOutputManagerV1) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -334,7 +334,7 @@ func NewZwlrOutputHeadV1(ctx *client.Context) *ZwlrOutputHeadV1 {
// This request indicates that the client will no longer use this head
// object.
func (i *ZwlrOutputHeadV1) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -879,7 +879,7 @@ func NewZwlrOutputModeV1(ctx *client.Context) *ZwlrOutputModeV1 {
// This request indicates that the client will no longer use this mode
// object.
func (i *ZwlrOutputModeV1) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -1132,7 +1132,7 @@ func (i *ZwlrOutputConfigurationV1) Test() error {
// This request also destroys wlr_output_configuration_head objects created
// via this object.
func (i *ZwlrOutputConfigurationV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 4
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -1415,7 +1415,7 @@ func (i *ZwlrOutputConfigurationHeadV1) SetAdaptiveSync(state uint32) error {
}
func (i *ZwlrOutputConfigurationHeadV1) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -79,7 +79,7 @@ func (i *ZwlrOutputPowerManagerV1) GetOutputPower(output *client.Output) (*ZwlrO
// All objects created by the manager will still remain valid, until their
// appropriate destroy request has been called.
func (i *ZwlrOutputPowerManagerV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -143,7 +143,7 @@ func (i *ZwlrOutputPowerV1) SetMode(mode uint32) error {
//
// Destroys the output power management mode control object.
func (i *ZwlrOutputPowerV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -120,7 +120,7 @@ func (i *ZwlrScreencopyManagerV1) CaptureOutputRegion(overlayCursor int32, outpu
// All objects created by the manager will still remain valid, until their
// appropriate destroy request has been called.
func (i *ZwlrScreencopyManagerV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 2
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -219,7 +219,7 @@ func (i *ZwlrScreencopyFrameV1) Copy(buffer *client.Buffer) error {
//
// Destroys the frame. This request can be sent at any time by the client.
func (i *ZwlrScreencopyFrameV1) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -66,7 +66,7 @@ func NewWpViewporter(ctx *client.Context) *WpViewporter {
// protocol object anymore. This does not affect any other objects,
// wp_viewport objects included.
func (i *WpViewporter) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -267,7 +267,7 @@ func NewWpViewport(ctx *client.Context) *WpViewport {
// The associated wl_surface's crop and scale state is removed.
// The change is applied on the next wl_surface.commit.
func (i *WpViewport) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
+523
View File
@@ -0,0 +1,523 @@
package screenshot
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type Compositor int
const (
CompositorUnknown Compositor = iota
CompositorHyprland
CompositorSway
CompositorNiri
CompositorDWL
)
var detectedCompositor Compositor = -1
func DetectCompositor() Compositor {
if detectedCompositor >= 0 {
return detectedCompositor
}
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
niriSocket := os.Getenv("NIRI_SOCKET")
swaySocket := os.Getenv("SWAYSOCK")
switch {
case niriSocket != "":
if _, err := os.Stat(niriSocket); err == nil {
detectedCompositor = CompositorNiri
return detectedCompositor
}
case swaySocket != "":
if _, err := os.Stat(swaySocket); err == nil {
detectedCompositor = CompositorSway
return detectedCompositor
}
case hyprlandSig != "":
detectedCompositor = CompositorHyprland
return detectedCompositor
}
if detectDWLProtocol() {
detectedCompositor = CompositorDWL
return detectedCompositor
}
detectedCompositor = CompositorUnknown
return detectedCompositor
}
func detectDWLProtocol() bool {
display, err := client.Connect("")
if err != nil {
return false
}
ctx := display.Context()
defer ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return false
}
found := false
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
if e.Interface == dwl_ipc.ZdwlIpcManagerV2InterfaceName {
found = true
}
})
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return false
}
return found
}
func SetCompositorDWL() {
detectedCompositor = CompositorDWL
}
type WindowGeometry struct {
X int32
Y int32
Width int32
Height int32
Output string
Scale float64
}
func GetActiveWindow() (*WindowGeometry, error) {
switch DetectCompositor() {
case CompositorHyprland:
return getHyprlandActiveWindow()
case CompositorDWL:
return getDWLActiveWindow()
default:
return nil, fmt.Errorf("window capture requires Hyprland or DWL")
}
}
type hyprlandWindow struct {
At [2]int32 `json:"at"`
Size [2]int32 `json:"size"`
}
func getHyprlandActiveWindow() (*WindowGeometry, error) {
output, err := exec.Command("hyprctl", "-j", "activewindow").Output()
if err != nil {
return nil, fmt.Errorf("hyprctl activewindow: %w", err)
}
var win hyprlandWindow
if err := json.Unmarshal(output, &win); err != nil {
return nil, fmt.Errorf("parse activewindow: %w", err)
}
if win.Size[0] <= 0 || win.Size[1] <= 0 {
return nil, fmt.Errorf("no active window")
}
return &WindowGeometry{
X: win.At[0],
Y: win.At[1],
Width: win.Size[0],
Height: win.Size[1],
}, nil
}
type hyprlandMonitor struct {
Name string `json:"name"`
X int32 `json:"x"`
Y int32 `json:"y"`
Width int32 `json:"width"`
Height int32 `json:"height"`
Scale float64 `json:"scale"`
Focused bool `json:"focused"`
}
func GetHyprlandMonitorScale(name string) float64 {
output, err := exec.Command("hyprctl", "-j", "monitors").Output()
if err != nil {
return 0
}
var monitors []hyprlandMonitor
if err := json.Unmarshal(output, &monitors); err != nil {
return 0
}
for _, m := range monitors {
if m.Name == name {
return m.Scale
}
}
return 0
}
func getHyprlandFocusedMonitor() string {
output, err := exec.Command("hyprctl", "-j", "monitors").Output()
if err != nil {
return ""
}
var monitors []hyprlandMonitor
if err := json.Unmarshal(output, &monitors); err != nil {
return ""
}
for _, m := range monitors {
if m.Focused {
return m.Name
}
}
return ""
}
func GetHyprlandMonitorGeometry(name string) (x, y, w, h int32, ok bool) {
output, err := exec.Command("hyprctl", "-j", "monitors").Output()
if err != nil {
return 0, 0, 0, 0, false
}
var monitors []hyprlandMonitor
if err := json.Unmarshal(output, &monitors); err != nil {
return 0, 0, 0, 0, false
}
for _, m := range monitors {
if m.Name == name {
logicalW := int32(float64(m.Width) / m.Scale)
logicalH := int32(float64(m.Height) / m.Scale)
return m.X, m.Y, logicalW, logicalH, true
}
}
return 0, 0, 0, 0, false
}
type swayWorkspace struct {
Output string `json:"output"`
Focused bool `json:"focused"`
}
func getSwayFocusedMonitor() string {
output, err := exec.Command("swaymsg", "-t", "get_workspaces").Output()
if err != nil {
return ""
}
var workspaces []swayWorkspace
if err := json.Unmarshal(output, &workspaces); err != nil {
return ""
}
for _, ws := range workspaces {
if ws.Focused {
return ws.Output
}
}
return ""
}
type niriWorkspace struct {
Output string `json:"output"`
IsFocused bool `json:"is_focused"`
}
func getNiriFocusedMonitor() string {
output, err := exec.Command("niri", "msg", "-j", "workspaces").Output()
if err != nil {
return ""
}
var workspaces []niriWorkspace
if err := json.Unmarshal(output, &workspaces); err != nil {
return ""
}
for _, ws := range workspaces {
if ws.IsFocused {
return ws.Output
}
}
return ""
}
var dwlActiveOutput string
func SetDWLActiveOutput(name string) {
dwlActiveOutput = name
}
func getDWLFocusedMonitor() string {
if dwlActiveOutput != "" {
return dwlActiveOutput
}
return queryDWLActiveOutput()
}
func queryDWLActiveOutput() string {
display, err := client.Connect("")
if err != nil {
return ""
}
ctx := display.Context()
defer ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return ""
}
var dwlManager *dwl_ipc.ZdwlIpcManagerV2
outputs := make(map[uint32]*client.Output)
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
switch e.Interface {
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
dwlManager = mgr
}
case client.OutputInterfaceName:
out := client.NewOutput(ctx)
version := e.Version
if version > 4 {
version = 4
}
if err := registry.Bind(e.Name, e.Interface, version, out); err == nil {
outputs[e.Name] = out
}
}
})
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return ""
}
if dwlManager == nil || len(outputs) == 0 {
return ""
}
outputNames := make(map[uint32]string)
for name, out := range outputs {
n := name
out.SetNameHandler(func(e client.OutputNameEvent) {
outputNames[n] = e.Name
})
}
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return ""
}
type outputState struct {
name string
active bool
gotFrame bool
}
states := make(map[uint32]*outputState)
for name, out := range outputs {
dwlOut, err := dwlManager.GetOutput(out)
if err != nil {
continue
}
state := &outputState{name: outputNames[name]}
states[name] = state
dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
state.active = e.Active != 0
})
dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
state.gotFrame = true
})
}
allFramesReceived := func() bool {
for _, s := range states {
if !s.gotFrame {
return false
}
}
return true
}
for !allFramesReceived() {
if err := ctx.Dispatch(); err != nil {
return ""
}
}
for _, state := range states {
if state.active {
return state.name
}
}
return ""
}
func GetFocusedMonitor() string {
switch DetectCompositor() {
case CompositorHyprland:
return getHyprlandFocusedMonitor()
case CompositorSway:
return getSwayFocusedMonitor()
case CompositorNiri:
return getNiriFocusedMonitor()
case CompositorDWL:
return getDWLFocusedMonitor()
}
return ""
}
func getDWLActiveWindow() (*WindowGeometry, error) {
display, err := client.Connect("")
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
ctx := display.Context()
defer ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return nil, fmt.Errorf("get registry: %w", err)
}
var dwlManager *dwl_ipc.ZdwlIpcManagerV2
outputs := make(map[uint32]*client.Output)
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
switch e.Interface {
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
dwlManager = mgr
}
case client.OutputInterfaceName:
out := client.NewOutput(ctx)
version := e.Version
if version > 4 {
version = 4
}
if err := registry.Bind(e.Name, e.Interface, version, out); err == nil {
outputs[e.Name] = out
}
}
})
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return nil, fmt.Errorf("roundtrip: %w", err)
}
if dwlManager == nil {
return nil, fmt.Errorf("dwl_ipc_manager not available")
}
if len(outputs) == 0 {
return nil, fmt.Errorf("no outputs found")
}
outputNames := make(map[uint32]string)
for name, out := range outputs {
n := name
out.SetNameHandler(func(e client.OutputNameEvent) {
outputNames[n] = e.Name
})
}
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return nil, fmt.Errorf("roundtrip: %w", err)
}
type dwlOutputState struct {
output *dwl_ipc.ZdwlIpcOutputV2
name string
active bool
x, y int32
w, h int32
scalefactor uint32
gotFrame bool
}
dwlOutputs := make(map[uint32]*dwlOutputState)
for name, out := range outputs {
dwlOut, err := dwlManager.GetOutput(out)
if err != nil {
continue
}
state := &dwlOutputState{output: dwlOut, name: outputNames[name]}
dwlOutputs[name] = state
dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
state.active = e.Active != 0
})
dwlOut.SetXHandler(func(e dwl_ipc.ZdwlIpcOutputV2XEvent) {
state.x = e.X
})
dwlOut.SetYHandler(func(e dwl_ipc.ZdwlIpcOutputV2YEvent) {
state.y = e.Y
})
dwlOut.SetWidthHandler(func(e dwl_ipc.ZdwlIpcOutputV2WidthEvent) {
state.w = e.Width
})
dwlOut.SetHeightHandler(func(e dwl_ipc.ZdwlIpcOutputV2HeightEvent) {
state.h = e.Height
})
dwlOut.SetScalefactorHandler(func(e dwl_ipc.ZdwlIpcOutputV2ScalefactorEvent) {
state.scalefactor = e.Scalefactor
})
dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
state.gotFrame = true
})
}
allFramesReceived := func() bool {
for _, s := range dwlOutputs {
if !s.gotFrame {
return false
}
}
return true
}
for !allFramesReceived() {
if err := ctx.Dispatch(); err != nil {
return nil, fmt.Errorf("dispatch: %w", err)
}
}
for _, state := range dwlOutputs {
if !state.active {
continue
}
if state.w <= 0 || state.h <= 0 {
return nil, fmt.Errorf("no active window")
}
scale := float64(state.scalefactor) / 100.0
if scale <= 0 {
scale = 1.0
}
return &WindowGeometry{
X: state.x,
Y: state.y,
Width: state.w,
Height: state.h,
Output: state.name,
Scale: scale,
}, nil
}
return nil, fmt.Errorf("no active output found")
}
+197
View File
@@ -0,0 +1,197 @@
package screenshot
import (
"bufio"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"time"
)
func BufferToImage(buf *ShmBuffer) *image.RGBA {
return BufferToImageWithFormat(buf, uint32(FormatARGB8888))
}
func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, buf.Width, buf.Height))
data := buf.Data()
swapRB := format == uint32(FormatARGB8888) || format == uint32(FormatXRGB8888) || format == 0
for y := 0; y < buf.Height; y++ {
srcOff := y * buf.Stride
dstOff := y * img.Stride
for x := 0; x < buf.Width; x++ {
si := srcOff + x*4
di := dstOff + x*4
if si+3 >= len(data) || di+3 >= len(img.Pix) {
continue
}
if swapRB {
img.Pix[di+0] = data[si+2]
img.Pix[di+1] = data[si+1]
img.Pix[di+2] = data[si+0]
} else {
img.Pix[di+0] = data[si+0]
img.Pix[di+1] = data[si+1]
img.Pix[di+2] = data[si+2]
}
img.Pix[di+3] = 255
}
}
return img
}
func EncodePNG(w io.Writer, img image.Image) error {
enc := png.Encoder{CompressionLevel: png.BestSpeed}
return enc.Encode(w, img)
}
func EncodeJPEG(w io.Writer, img image.Image, quality int) error {
return jpeg.Encode(w, img, &jpeg.Options{Quality: quality})
}
func EncodePPM(w io.Writer, img *image.RGBA) error {
bw := bufio.NewWriter(w)
bounds := img.Bounds()
if _, err := fmt.Fprintf(bw, "P6\n%d %d\n255\n", bounds.Dx(), bounds.Dy()); err != nil {
return err
}
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
off := (y-bounds.Min.Y)*img.Stride + (x-bounds.Min.X)*4
if err := bw.WriteByte(img.Pix[off+0]); err != nil {
return err
}
if err := bw.WriteByte(img.Pix[off+1]); err != nil {
return err
}
if err := bw.WriteByte(img.Pix[off+2]); err != nil {
return err
}
}
}
return bw.Flush()
}
func GenerateFilename(format Format) string {
t := time.Now()
ext := "png"
switch format {
case FormatJPEG:
ext = "jpg"
case FormatPPM:
ext = "ppm"
}
return fmt.Sprintf("screenshot_%s.%s", t.Format("2006-01-02_15-04-05"), ext)
}
func GetOutputDir() string {
if dir := os.Getenv("DMS_SCREENSHOT_DIR"); dir != "" {
return dir
}
if xdgPics := getXDGPicturesDir(); xdgPics != "" {
screenshotDir := filepath.Join(xdgPics, "Screenshots")
if err := os.MkdirAll(screenshotDir, 0755); err == nil {
return screenshotDir
}
return xdgPics
}
if home := os.Getenv("HOME"); home != "" {
return home
}
return "."
}
func getXDGPicturesDir() string {
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
home := os.Getenv("HOME")
if home == "" {
return ""
}
configDir = filepath.Join(home, ".config")
}
userDirsFile := filepath.Join(configDir, "user-dirs.dirs")
data, err := os.ReadFile(userDirsFile)
if err != nil {
return ""
}
for _, line := range splitLines(string(data)) {
if len(line) == 0 || line[0] == '#' {
continue
}
const prefix = "XDG_PICTURES_DIR="
if len(line) > len(prefix) && line[:len(prefix)] == prefix {
path := line[len(prefix):]
path = trimQuotes(path)
path = expandHome(path)
return path
}
}
return ""
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func trimQuotes(s string) string {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
func expandHome(path string) string {
if len(path) >= 5 && path[:5] == "$HOME" {
home := os.Getenv("HOME")
return home + path[5:]
}
if len(path) >= 1 && path[0] == '~' {
home := os.Getenv("HOME")
return home + path[1:]
}
return path
}
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
}
func WriteToFileWithFormat(buf *ShmBuffer, path string, format Format, quality int, pixelFormat uint32) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
img := BufferToImageWithFormat(buf, pixelFormat)
switch format {
case FormatJPEG:
return EncodeJPEG(f, img, quality)
case FormatPPM:
return EncodePPM(f, img)
default:
return EncodePNG(f, img)
}
}
+180
View File
@@ -0,0 +1,180 @@
package screenshot
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5"
)
const (
notifyDest = "org.freedesktop.Notifications"
notifyPath = "/org/freedesktop/Notifications"
notifyInterface = "org.freedesktop.Notifications"
)
type NotifyResult struct {
FilePath string
Clipboard bool
ImageData []byte
Width int
Height int
}
func SendNotification(result NotifyResult) {
conn, err := dbus.SessionBus()
if err != nil {
log.Debug("dbus session failed", "err", err)
return
}
var actions []string
if result.FilePath != "" {
actions = []string{"default", "Open"}
}
hints := map[string]dbus.Variant{}
if len(result.ImageData) > 0 && result.Width > 0 && result.Height > 0 {
rowstride := result.Width * 3
hints["image_data"] = dbus.MakeVariant(struct {
Width int32
Height int32
Rowstride int32
HasAlpha bool
BitsPerSample int32
Channels int32
Data []byte
}{
Width: int32(result.Width),
Height: int32(result.Height),
Rowstride: int32(rowstride),
HasAlpha: false,
BitsPerSample: 8,
Channels: 3,
Data: result.ImageData,
})
} else if result.FilePath != "" {
hints["image_path"] = dbus.MakeVariant(result.FilePath)
}
summary := "Screenshot captured"
body := ""
if result.Clipboard && result.FilePath != "" {
body = fmt.Sprintf("Copied to clipboard\n%s", filepath.Base(result.FilePath))
} else if result.Clipboard {
body = "Copied to clipboard"
} else if result.FilePath != "" {
body = filepath.Base(result.FilePath)
}
obj := conn.Object(notifyDest, notifyPath)
call := obj.Call(
notifyInterface+".Notify",
0,
"DMS",
uint32(0),
"",
summary,
body,
actions,
hints,
int32(5000),
)
if call.Err != nil {
log.Debug("notify call failed", "err", call.Err)
return
}
var notificationID uint32
if err := call.Store(&notificationID); err != nil {
log.Debug("failed to get notification id", "err", err)
return
}
if len(actions) == 0 || result.FilePath == "" {
return
}
spawnActionListener(notificationID, result.FilePath)
}
func spawnActionListener(notificationID uint32, filePath string) {
exe, err := os.Executable()
if err != nil {
log.Debug("failed to get executable", "err", err)
return
}
cmd := exec.Command(exe, "notify-action", fmt.Sprintf("%d", notificationID), filePath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}
func RunNotifyActionListener(args []string) {
if len(args) < 2 {
return
}
notificationID, err := strconv.ParseUint(args[0], 10, 32)
if err != nil {
return
}
filePath := args[1]
conn, err := dbus.SessionBus()
if err != nil {
return
}
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(notifyPath),
dbus.WithMatchInterface(notifyInterface),
); err != nil {
return
}
signals := make(chan *dbus.Signal, 10)
conn.Signal(signals)
for sig := range signals {
switch sig.Name {
case notifyInterface + ".ActionInvoked":
if len(sig.Body) < 2 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok || id != uint32(notificationID) {
continue
}
openFile(filePath)
return
case notifyInterface + ".NotificationClosed":
if len(sig.Body) < 1 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok || id != uint32(notificationID) {
continue
}
return
}
}
}
func openFile(filePath string) {
cmd := exec.Command("xdg-open", filePath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}
+809
View File
@@ -0,0 +1,809 @@
package screenshot
import (
"fmt"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type SelectionState struct {
hasSelection bool // There's a selection to display (pre-loaded or user-drawn)
dragging bool // User is actively drawing a new selection
surface *OutputSurface // Surface where selection was made
// Surface-local logical coordinates (from pointer events)
anchorX float64
anchorY float64
currentX float64
currentY float64
}
type RenderSlot struct {
shm *ShmBuffer
pool *client.ShmPool
wlBuf *client.Buffer
busy bool
}
type OutputSurface struct {
output *WaylandOutput
wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport
screenBuf *ShmBuffer
screenBufNoCursor *ShmBuffer
screenFormat uint32
logicalW int
logicalH int
configured bool
yInverted bool
// Triple-buffered render slots
slots [3]*RenderSlot
slotsReady bool
}
type PreCapture struct {
screenBuf *ShmBuffer
screenBufNoCursor *ShmBuffer
format uint32
yInverted bool
}
type RegionSelector struct {
screenshoter *Screenshoter
display *client.Display
registry *client.Registry
ctx *client.Context
compositor *client.Compositor
shm *client.Shm
seat *client.Seat
pointer *client.Pointer
keyboard *client.Keyboard
layerShell *wlr_layer_shell.ZwlrLayerShellV1
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter
shortcutsInhibitMgr *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1
shortcutsInhibitor *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitorV1
outputs map[uint32]*WaylandOutput
outputsMu sync.Mutex
preCapture map[*WaylandOutput]*PreCapture
surfaces []*OutputSurface
activeSurface *OutputSurface
// Cursor surface for crosshair
cursorSurface *client.Surface
cursorBuffer *ShmBuffer
cursorWlBuf *client.Buffer
cursorPool *client.ShmPool
selection SelectionState
pointerX float64
pointerY float64
preSelect Region
showCapturedCursor bool
shiftHeld bool
running bool
cancelled bool
result Region
capturedBuffer *ShmBuffer
capturedRegion Region
}
func NewRegionSelector(s *Screenshoter) *RegionSelector {
return &RegionSelector{
screenshoter: s,
outputs: make(map[uint32]*WaylandOutput),
preCapture: make(map[*WaylandOutput]*PreCapture),
showCapturedCursor: true,
}
}
func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
r.preSelect = GetLastRegion()
if err := r.connect(); err != nil {
return nil, false, fmt.Errorf("wayland connect: %w", err)
}
defer r.cleanup()
if err := r.setupRegistry(); err != nil {
return nil, false, fmt.Errorf("registry setup: %w", err)
}
if err := r.roundtrip(); err != nil {
return nil, false, fmt.Errorf("roundtrip after registry: %w", err)
}
switch {
case r.screencopy == nil:
return nil, false, fmt.Errorf("compositor does not support wlr-screencopy-unstable-v1")
case r.layerShell == nil:
return nil, false, fmt.Errorf("compositor does not support wlr-layer-shell-unstable-v1")
case r.seat == nil:
return nil, false, fmt.Errorf("no seat available")
case r.compositor == nil:
return nil, false, fmt.Errorf("compositor not available")
case r.shm == nil:
return nil, false, fmt.Errorf("wl_shm not available")
case len(r.outputs) == 0:
return nil, false, fmt.Errorf("no outputs available")
}
if err := r.roundtrip(); err != nil {
return nil, false, fmt.Errorf("roundtrip after protocol check: %w", err)
}
if err := r.preCaptureAllOutputs(); err != nil {
return nil, false, fmt.Errorf("pre-capture: %w", err)
}
if err := r.createSurfaces(); err != nil {
return nil, false, fmt.Errorf("create surfaces: %w", err)
}
_ = r.createCursor()
if err := r.roundtrip(); err != nil {
return nil, false, fmt.Errorf("roundtrip after surfaces: %w", err)
}
r.running = true
for r.running {
if err := r.ctx.Dispatch(); err != nil {
return nil, false, fmt.Errorf("dispatch: %w", err)
}
}
if r.cancelled || r.capturedBuffer == nil {
return nil, r.cancelled, nil
}
yInverted := false
var format uint32
if r.selection.surface != nil {
yInverted = r.selection.surface.yInverted
format = r.selection.surface.screenFormat
}
return &CaptureResult{
Buffer: r.capturedBuffer,
Region: r.result,
YInverted: yInverted,
Format: format,
}, false, nil
}
func (r *RegionSelector) connect() error {
display, err := client.Connect("")
if err != nil {
return err
}
r.display = display
r.ctx = display.Context()
return nil
}
func (r *RegionSelector) roundtrip() error {
return wlhelpers.Roundtrip(r.display, r.ctx)
}
func (r *RegionSelector) setupRegistry() error {
registry, err := r.display.GetRegistry()
if err != nil {
return err
}
r.registry = registry
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
r.handleGlobal(e)
})
registry.SetGlobalRemoveHandler(func(e client.RegistryGlobalRemoveEvent) {
r.outputsMu.Lock()
delete(r.outputs, e.Name)
r.outputsMu.Unlock()
})
return nil
}
func (r *RegionSelector) handleGlobal(e client.RegistryGlobalEvent) {
switch e.Interface {
case client.CompositorInterfaceName:
comp := client.NewCompositor(r.ctx)
if err := r.registry.Bind(e.Name, e.Interface, e.Version, comp); err == nil {
r.compositor = comp
}
case client.ShmInterfaceName:
shm := client.NewShm(r.ctx)
if err := r.registry.Bind(e.Name, e.Interface, e.Version, shm); err == nil {
r.shm = shm
}
case client.SeatInterfaceName:
seat := client.NewSeat(r.ctx)
if err := r.registry.Bind(e.Name, e.Interface, e.Version, seat); err == nil {
r.seat = seat
r.setupInput()
}
case client.OutputInterfaceName:
output := client.NewOutput(r.ctx)
version := e.Version
if version > 4 {
version = 4
}
if err := r.registry.Bind(e.Name, e.Interface, version, output); err == nil {
r.outputsMu.Lock()
r.outputs[e.Name] = &WaylandOutput{
wlOutput: output,
globalName: e.Name,
scale: 1,
fractionalScale: 1.0,
}
r.outputsMu.Unlock()
r.setupOutputHandlers(e.Name, output)
}
case wlr_layer_shell.ZwlrLayerShellV1InterfaceName:
ls := wlr_layer_shell.NewZwlrLayerShellV1(r.ctx)
version := e.Version
if version > 4 {
version = 4
}
if err := r.registry.Bind(e.Name, e.Interface, version, ls); err == nil {
r.layerShell = ls
}
case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName:
sc := wlr_screencopy.NewZwlrScreencopyManagerV1(r.ctx)
version := e.Version
if version > 3 {
version = 3
}
if err := r.registry.Bind(e.Name, e.Interface, version, sc); err == nil {
r.screencopy = sc
}
case wp_viewporter.WpViewporterInterfaceName:
vp := wp_viewporter.NewWpViewporter(r.ctx)
if err := r.registry.Bind(e.Name, e.Interface, e.Version, vp); err == nil {
r.viewporter = vp
}
case keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1InterfaceName:
mgr := keyboard_shortcuts_inhibit.NewZwpKeyboardShortcutsInhibitManagerV1(r.ctx)
if err := r.registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
r.shortcutsInhibitMgr = mgr
}
}
}
func (r *RegionSelector) setupOutputHandlers(name uint32, output *client.Output) {
output.SetGeometryHandler(func(e client.OutputGeometryEvent) {
r.outputsMu.Lock()
if o, ok := r.outputs[name]; ok {
o.x = e.X
o.y = e.Y
o.transform = int32(e.Transform)
}
r.outputsMu.Unlock()
})
output.SetModeHandler(func(e client.OutputModeEvent) {
if e.Flags&uint32(client.OutputModeCurrent) == 0 {
return
}
r.outputsMu.Lock()
if o, ok := r.outputs[name]; ok {
o.width = e.Width
o.height = e.Height
}
r.outputsMu.Unlock()
})
output.SetScaleHandler(func(e client.OutputScaleEvent) {
r.outputsMu.Lock()
if o, ok := r.outputs[name]; ok {
o.scale = e.Factor
o.fractionalScale = float64(e.Factor)
}
r.outputsMu.Unlock()
})
output.SetNameHandler(func(e client.OutputNameEvent) {
r.outputsMu.Lock()
if o, ok := r.outputs[name]; ok {
o.name = e.Name
}
r.outputsMu.Unlock()
})
}
func (r *RegionSelector) preCaptureAllOutputs() error {
r.outputsMu.Lock()
outputs := make([]*WaylandOutput, 0, len(r.outputs))
for _, o := range r.outputs {
outputs = append(outputs, o)
}
r.outputsMu.Unlock()
pending := len(outputs) * 2
done := make(chan struct{}, pending)
for _, output := range outputs {
pc := &PreCapture{}
r.preCapture[output] = pc
r.preCaptureOutput(output, pc, true, func() { done <- struct{}{} })
r.preCaptureOutput(output, pc, false, func() { done <- struct{}{} })
}
for i := 0; i < pending; i++ {
if err := r.ctx.Dispatch(); err != nil {
return err
}
select {
case <-done:
default:
i--
}
}
return nil
}
func (r *RegionSelector) preCaptureOutput(output *WaylandOutput, pc *PreCapture, withCursor bool, onReady func()) {
cursor := int32(0)
if withCursor {
cursor = 1
}
frame, err := r.screencopy.CaptureOutput(cursor, output.wlOutput)
if err != nil {
log.Error("screencopy capture failed", "err", err)
onReady()
return
}
frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) {
buf, err := CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
if err != nil {
log.Error("create screen buffer failed", "err", err)
return
}
if withCursor {
pc.screenBuf = buf
pc.format = e.Format
} else {
pc.screenBufNoCursor = buf
}
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil {
log.Error("create shm pool failed", "err", err)
return
}
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), e.Format)
if err != nil {
log.Error("create wl_buffer failed", "err", err)
pool.Destroy()
return
}
if err := frame.Copy(wlBuf); err != nil {
log.Error("frame copy failed", "err", err)
}
pool.Destroy()
})
frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) {
if withCursor {
pc.yInverted = (e.Flags & 1) != 0
}
})
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
frame.Destroy()
onReady()
})
frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) {
log.Error("screencopy failed")
frame.Destroy()
onReady()
})
}
func (r *RegionSelector) createSurfaces() error {
r.outputsMu.Lock()
outputs := make([]*WaylandOutput, 0, len(r.outputs))
for _, o := range r.outputs {
outputs = append(outputs, o)
}
r.outputsMu.Unlock()
for _, output := range outputs {
os, err := r.createOutputSurface(output)
if err != nil {
return fmt.Errorf("output %s: %w", output.name, err)
}
r.surfaces = append(r.surfaces, os)
}
return nil
}
func (r *RegionSelector) createCursor() error {
const size = 24
const hotspot = size / 2
surface, err := r.compositor.CreateSurface()
if err != nil {
return fmt.Errorf("create cursor surface: %w", err)
}
r.cursorSurface = surface
buf, err := CreateShmBuffer(size, size, size*4)
if err != nil {
return fmt.Errorf("create cursor buffer: %w", err)
}
r.cursorBuffer = buf
// Draw crosshair
data := buf.Data()
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
off := (y*size + x) * 4
// Vertical line
if x >= hotspot-1 && x <= hotspot && y >= 2 && y < size-2 {
data[off+0] = 255 // B
data[off+1] = 255 // G
data[off+2] = 255 // R
data[off+3] = 255 // A
continue
}
// Horizontal line
if y >= hotspot-1 && y <= hotspot && x >= 2 && x < size-2 {
data[off+0] = 255
data[off+1] = 255
data[off+2] = 255
data[off+3] = 255
continue
}
// Transparent
data[off+0] = 0
data[off+1] = 0
data[off+2] = 0
data[off+3] = 0
}
}
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil {
return fmt.Errorf("create cursor pool: %w", err)
}
r.cursorPool = pool
wlBuf, err := pool.CreateBuffer(0, size, size, size*4, uint32(FormatARGB8888))
if err != nil {
return fmt.Errorf("create cursor wl_buffer: %w", err)
}
r.cursorWlBuf = wlBuf
if err := surface.Attach(wlBuf, 0, 0); err != nil {
return fmt.Errorf("attach cursor: %w", err)
}
if err := surface.Damage(0, 0, size, size); err != nil {
return fmt.Errorf("damage cursor: %w", err)
}
if err := surface.Commit(); err != nil {
return fmt.Errorf("commit cursor: %w", err)
}
return nil
}
func (r *RegionSelector) createOutputSurface(output *WaylandOutput) (*OutputSurface, error) {
surface, err := r.compositor.CreateSurface()
if err != nil {
return nil, fmt.Errorf("create surface: %w", err)
}
layerSurf, err := r.layerShell.GetLayerSurface(
surface,
output.wlOutput,
uint32(wlr_layer_shell.ZwlrLayerShellV1LayerOverlay),
"dms-screenshot",
)
if err != nil {
return nil, fmt.Errorf("get layer surface: %w", err)
}
os := &OutputSurface{
output: output,
wlSurface: surface,
layerSurf: layerSurf,
}
if r.viewporter != nil {
vp, err := r.viewporter.GetViewport(surface)
if err == nil {
os.viewport = vp
}
}
if err := layerSurf.SetAnchor(
uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorTop) |
uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorBottom) |
uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorLeft) |
uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorRight),
); err != nil {
return nil, fmt.Errorf("set anchor: %w", err)
}
if err := layerSurf.SetExclusiveZone(-1); err != nil {
return nil, fmt.Errorf("set exclusive zone: %w", err)
}
if err := layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityExclusive)); err != nil {
return nil, fmt.Errorf("set keyboard interactivity: %w", err)
}
layerSurf.SetConfigureHandler(func(e wlr_layer_shell.ZwlrLayerSurfaceV1ConfigureEvent) {
if err := layerSurf.AckConfigure(e.Serial); err != nil {
log.Error("ack configure failed", "err", err)
return
}
os.logicalW = int(e.Width)
os.logicalH = int(e.Height)
os.configured = true
r.captureForSurface(os)
r.ensureShortcutsInhibitor(os)
})
layerSurf.SetClosedHandler(func(e wlr_layer_shell.ZwlrLayerSurfaceV1ClosedEvent) {
r.running = false
r.cancelled = true
})
if err := surface.Commit(); err != nil {
return nil, fmt.Errorf("surface commit: %w", err)
}
return os, nil
}
func (r *RegionSelector) ensureShortcutsInhibitor(os *OutputSurface) {
if r.shortcutsInhibitMgr == nil || r.seat == nil || r.shortcutsInhibitor != nil {
return
}
inhibitor, err := r.shortcutsInhibitMgr.InhibitShortcuts(os.wlSurface, r.seat)
if err == nil {
r.shortcutsInhibitor = inhibitor
}
}
func (r *RegionSelector) captureForSurface(os *OutputSurface) {
pc := r.preCapture[os.output]
if pc == nil {
return
}
os.screenBuf = pc.screenBuf
os.screenBufNoCursor = pc.screenBufNoCursor
os.screenFormat = pc.format
os.yInverted = pc.yInverted
if os.logicalW > 0 && os.screenBuf != nil {
os.output.fractionalScale = float64(os.screenBuf.Width) / float64(os.logicalW)
}
r.initRenderBuffer(os)
r.applyPreSelection(os)
r.redrawSurface(os)
}
func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
if os.screenBuf == nil {
return
}
for i := 0; i < 3; i++ {
slot := &RenderSlot{}
buf, err := CreateShmBuffer(os.screenBuf.Width, os.screenBuf.Height, os.screenBuf.Stride)
if err != nil {
log.Error("create render slot buffer failed", "err", err)
return
}
slot.shm = buf
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil {
log.Error("create render slot pool failed", "err", err)
buf.Close()
return
}
slot.pool = pool
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat)
if err != nil {
log.Error("create render slot wl_buffer failed", "err", err)
pool.Destroy()
buf.Close()
return
}
slot.wlBuf = wlBuf
slotRef := slot
wlBuf.SetReleaseHandler(func(e client.BufferReleaseEvent) {
slotRef.busy = false
})
os.slots[i] = slot
}
os.slotsReady = true
}
func (os *OutputSurface) acquireFreeSlot() *RenderSlot {
for _, slot := range os.slots {
if slot != nil && !slot.busy {
return slot
}
}
return nil
}
func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
if r.preSelect.IsEmpty() || os.screenBuf == nil || r.selection.hasSelection {
return
}
if r.preSelect.Output != "" && r.preSelect.Output != os.output.name {
return
}
scaleX := float64(os.logicalW) / float64(os.screenBuf.Width)
scaleY := float64(os.logicalH) / float64(os.screenBuf.Height)
x1 := float64(r.preSelect.X-os.output.x) * scaleX
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY
r.selection.hasSelection = true
r.selection.dragging = false
r.selection.surface = os
r.selection.anchorX = x1
r.selection.anchorY = y1
r.selection.currentX = x2
r.selection.currentY = y2
r.activeSurface = os
}
func (r *RegionSelector) getSourceBuffer(os *OutputSurface) *ShmBuffer {
if !r.showCapturedCursor && os.screenBufNoCursor != nil {
return os.screenBufNoCursor
}
return os.screenBuf
}
func (r *RegionSelector) redrawSurface(os *OutputSurface) {
srcBuf := r.getSourceBuffer(os)
if srcBuf == nil || !os.slotsReady {
return
}
slot := os.acquireFreeSlot()
if slot == nil {
return
}
slot.shm.CopyFrom(srcBuf)
// Draw overlay (dimming + selection) into this slot
r.drawOverlay(os, slot.shm)
if os.viewport != nil {
_ = os.wlSurface.SetBufferScale(1)
_ = os.viewport.SetSource(0, 0, float64(slot.shm.Width), float64(slot.shm.Height))
_ = os.viewport.SetDestination(int32(os.logicalW), int32(os.logicalH))
} else {
bufferScale := os.output.scale
if bufferScale <= 0 {
bufferScale = 1
}
_ = os.wlSurface.SetBufferScale(bufferScale)
}
_ = os.wlSurface.Attach(slot.wlBuf, 0, 0)
_ = os.wlSurface.Damage(0, 0, int32(os.logicalW), int32(os.logicalH))
_ = os.wlSurface.Commit()
// Mark this slot as busy until compositor releases it
slot.busy = true
}
func (r *RegionSelector) cleanup() {
if r.cursorWlBuf != nil {
r.cursorWlBuf.Destroy()
}
if r.cursorPool != nil {
r.cursorPool.Destroy()
}
if r.cursorSurface != nil {
r.cursorSurface.Destroy()
}
if r.cursorBuffer != nil {
r.cursorBuffer.Close()
}
for _, os := range r.surfaces {
for _, slot := range os.slots {
if slot == nil {
continue
}
if slot.wlBuf != nil {
slot.wlBuf.Destroy()
}
if slot.pool != nil {
slot.pool.Destroy()
}
if slot.shm != nil {
slot.shm.Close()
}
}
if os.viewport != nil {
os.viewport.Destroy()
}
if os.layerSurf != nil {
os.layerSurf.Destroy()
}
if os.wlSurface != nil {
os.wlSurface.Destroy()
}
if os.screenBuf != nil {
os.screenBuf.Close()
}
if os.screenBufNoCursor != nil {
os.screenBufNoCursor.Close()
}
}
if r.shortcutsInhibitor != nil {
_ = r.shortcutsInhibitor.Destroy()
}
if r.shortcutsInhibitMgr != nil {
_ = r.shortcutsInhibitMgr.Destroy()
}
if r.viewporter != nil {
r.viewporter.Destroy()
}
if r.screencopy != nil {
r.screencopy.Destroy()
}
if r.pointer != nil {
r.pointer.Release()
}
if r.keyboard != nil {
r.keyboard.Release()
}
if r.display != nil {
r.ctx.Close()
}
}
+271
View File
@@ -0,0 +1,271 @@
package screenshot
import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
func (r *RegionSelector) setupInput() {
if r.seat == nil {
return
}
r.seat.SetCapabilitiesHandler(func(e client.SeatCapabilitiesEvent) {
if e.Capabilities&uint32(client.SeatCapabilityPointer) != 0 && r.pointer == nil {
if pointer, err := r.seat.GetPointer(); err == nil {
r.pointer = pointer
r.setupPointerHandlers()
}
}
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
if keyboard, err := r.seat.GetKeyboard(); err == nil {
r.keyboard = keyboard
r.setupKeyboardHandlers()
}
}
})
}
func (r *RegionSelector) setupPointerHandlers() {
r.pointer.SetEnterHandler(func(e client.PointerEnterEvent) {
if r.cursorSurface != nil {
_ = r.pointer.SetCursor(e.Serial, r.cursorSurface, 12, 12)
}
r.activeSurface = nil
for _, os := range r.surfaces {
if os.wlSurface.ID() == e.Surface.ID() {
r.activeSurface = os
break
}
}
r.pointerX = e.SurfaceX
r.pointerY = e.SurfaceY
})
r.pointer.SetMotionHandler(func(e client.PointerMotionEvent) {
if r.activeSurface == nil {
return
}
r.pointerX = e.SurfaceX
r.pointerY = e.SurfaceY
if !r.selection.dragging {
return
}
curX, curY := e.SurfaceX, e.SurfaceY
if r.shiftHeld {
dx := curX - r.selection.anchorX
dy := curY - r.selection.anchorY
adx, ady := dx, dy
if adx < 0 {
adx = -adx
}
if ady < 0 {
ady = -ady
}
size := adx
if ady > adx {
size = ady
}
if dx < 0 {
curX = r.selection.anchorX - size
} else {
curX = r.selection.anchorX + size
}
if dy < 0 {
curY = r.selection.anchorY - size
} else {
curY = r.selection.anchorY + size
}
}
r.selection.currentX = curX
r.selection.currentY = curY
for _, os := range r.surfaces {
r.redrawSurface(os)
}
})
r.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
if r.activeSurface == nil {
return
}
switch e.Button {
case 0x110: // BTN_LEFT
switch e.State {
case 1: // pressed
r.preSelect = Region{}
r.selection.hasSelection = true
r.selection.dragging = true
r.selection.surface = r.activeSurface
r.selection.anchorX = r.pointerX
r.selection.anchorY = r.pointerY
r.selection.currentX = r.pointerX
r.selection.currentY = r.pointerY
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case 0: // released
r.selection.dragging = false
for _, os := range r.surfaces {
r.redrawSurface(os)
}
}
default:
r.cancelled = true
r.running = false
}
})
}
func (r *RegionSelector) setupKeyboardHandlers() {
r.keyboard.SetModifiersHandler(func(e client.KeyboardModifiersEvent) {
r.shiftHeld = e.ModsDepressed&1 != 0
})
r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
if e.State != 1 {
return
}
switch e.Key {
case 1:
r.cancelled = true
r.running = false
case 25:
r.showCapturedCursor = !r.showCapturedCursor
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case 28, 57:
if r.selection.hasSelection {
r.finishSelection()
}
}
})
}
func (r *RegionSelector) finishSelection() {
if r.selection.surface == nil {
r.running = false
return
}
os := r.selection.surface
srcBuf := r.getSourceBuffer(os)
if srcBuf == nil {
r.running = false
return
}
x1, y1 := r.selection.anchorX, r.selection.anchorY
x2, y2 := r.selection.currentX, r.selection.currentY
if x1 > x2 {
x1, x2 = x2, x1
}
if y1 > y2 {
y1, y2 = y2, y1
}
scaleX, scaleY := 1.0, 1.0
if os.logicalW > 0 {
scaleX = float64(srcBuf.Width) / float64(os.logicalW)
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
}
bx1 := int(x1 * scaleX)
by1 := int(y1 * scaleY)
bx2 := int(x2 * scaleX)
by2 := int(y2 * scaleY)
// Clamp to buffer bounds
if bx1 < 0 {
bx1 = 0
}
if by1 < 0 {
by1 = 0
}
if bx2 > srcBuf.Width {
bx2 = srcBuf.Width
}
if by2 > srcBuf.Height {
by2 = srcBuf.Height
}
w, h := bx2-bx1+1, by2-by1+1
if r.shiftHeld && w != h {
if w < h {
h = w
} else {
w = h
}
}
if w < 1 {
w = 1
}
if h < 1 {
h = 1
}
// Create cropped buffer and copy pixels directly
cropped, err := CreateShmBuffer(w, h, w*4)
if err != nil {
r.running = false
return
}
srcData := srcBuf.Data()
dstData := cropped.Data()
for y := 0; y < h; y++ {
srcY := by1 + y
if os.yInverted {
srcY = srcBuf.Height - 1 - (by1 + y)
}
if srcY < 0 || srcY >= srcBuf.Height {
continue
}
dstY := y
if os.yInverted {
dstY = h - 1 - y
}
for x := 0; x < w; x++ {
srcX := bx1 + x
if srcX < 0 || srcX >= srcBuf.Width {
continue
}
si := srcY*srcBuf.Stride + srcX*4
di := dstY*cropped.Stride + x*4
if si+3 < len(srcData) && di+3 < len(dstData) {
dstData[di+0] = srcData[si+0]
dstData[di+1] = srcData[si+1]
dstData[di+2] = srcData[si+2]
dstData[di+3] = srcData[si+3]
}
}
}
r.capturedBuffer = cropped
r.capturedRegion = Region{
X: int32(bx1),
Y: int32(by1),
Width: int32(w),
Height: int32(h),
Output: os.output.name,
}
// Also store for "last region" feature with global coords
r.result = Region{
X: int32(bx1) + os.output.x,
Y: int32(by1) + os.output.y,
Width: int32(w),
Height: int32(h),
Output: os.output.name,
}
r.running = false
}
+322
View File
@@ -0,0 +1,322 @@
package screenshot
import "fmt"
var fontGlyphs = map[rune][12]uint8{
'0': {0x3C, 0x66, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
'1': {0x18, 0x38, 0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, 0x00},
'2': {0x3C, 0x66, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x66, 0x7E, 0x00, 0x00},
'3': {0x3C, 0x66, 0x06, 0x06, 0x1C, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00, 0x00},
'4': {0x0C, 0x1C, 0x3C, 0x6C, 0xCC, 0xCC, 0xFE, 0x0C, 0x0C, 0x1E, 0x00, 0x00},
'5': {0x7E, 0x60, 0x60, 0x60, 0x7C, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00, 0x00},
'6': {0x1C, 0x30, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
'7': {0x7E, 0x66, 0x06, 0x06, 0x0C, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00},
'8': {0x3C, 0x66, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
'9': {0x3C, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x06, 0x0C, 0x38, 0x00, 0x00},
'x': {0x00, 0x00, 0x00, 0x66, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x66, 0x00, 0x00},
'E': {0x7E, 0x60, 0x60, 0x60, 0x7C, 0x60, 0x60, 0x60, 0x60, 0x7E, 0x00, 0x00},
'P': {0x7C, 0x66, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x60, 0x60, 0x60, 0x00, 0x00},
'S': {0x3C, 0x66, 0x60, 0x60, 0x3C, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00, 0x00},
'a': {0x00, 0x00, 0x00, 0x3C, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00},
'c': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00, 0x00},
'd': {0x00, 0x00, 0x06, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00},
'e': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x66, 0x7E, 0x60, 0x60, 0x3C, 0x00, 0x00},
'h': {0x00, 0x60, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00},
'i': {0x00, 0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00, 0x00},
'n': {0x00, 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00},
'o': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
'p': {0x00, 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x00, 0x00},
'r': {0x00, 0x00, 0x00, 0x6E, 0x76, 0x60, 0x60, 0x60, 0x60, 0x60, 0x00, 0x00},
's': {0x00, 0x00, 0x00, 0x3E, 0x60, 0x60, 0x3C, 0x06, 0x06, 0x7C, 0x00, 0x00},
't': {0x00, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x0E, 0x00, 0x00},
'u': {0x00, 0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00},
'w': {0x00, 0x00, 0x00, 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00, 0x00},
'l': {0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00, 0x00},
' ': {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
':': {0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00},
'/': {0x00, 0x02, 0x06, 0x0C, 0x18, 0x18, 0x30, 0x60, 0x40, 0x00, 0x00, 0x00},
'[': {0x3C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3C, 0x00, 0x00},
']': {0x3C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x3C, 0x00, 0x00},
}
type OverlayStyle struct {
BackgroundR, BackgroundG, BackgroundB, BackgroundA uint8
TextR, TextG, TextB uint8
AccentR, AccentG, AccentB uint8
}
var DefaultOverlayStyle = OverlayStyle{
BackgroundR: 30, BackgroundG: 30, BackgroundB: 30, BackgroundA: 220,
TextR: 255, TextG: 255, TextB: 255,
AccentR: 100, AccentG: 180, AccentB: 255,
}
func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data := renderBuf.Data()
stride := renderBuf.Stride
w, h := renderBuf.Width, renderBuf.Height
format := os.screenFormat
// Dim the entire buffer
for y := 0; y < h; y++ {
off := y * stride
for x := 0; x < w; x++ {
i := off + x*4
if i+3 >= len(data) {
continue
}
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
}
}
r.drawHUD(data, stride, w, h, format)
if !r.selection.hasSelection || r.selection.surface != os {
return
}
scaleX := float64(w) / float64(os.logicalW)
scaleY := float64(h) / float64(os.logicalH)
bx1 := int(r.selection.anchorX * scaleX)
by1 := int(r.selection.anchorY * scaleY)
bx2 := int(r.selection.currentX * scaleX)
by2 := int(r.selection.currentY * scaleY)
if bx1 > bx2 {
bx1, bx2 = bx2, bx1
}
if by1 > by2 {
by1, by2 = by2, by1
}
bx1 = clamp(bx1, 0, w-1)
by1 = clamp(by1, 0, h-1)
bx2 = clamp(bx2, 0, w-1)
by2 = clamp(by2, 0, h-1)
srcBuf := r.getSourceBuffer(os)
srcData := srcBuf.Data()
for y := by1; y <= by2; y++ {
rowOff := y * stride
for x := bx1; x <= bx2; x++ {
si := y*srcBuf.Stride + x*4
di := rowOff + x*4
if si+3 >= len(srcData) || di+3 >= len(data) {
continue
}
data[di+0] = srcData[si+0]
data[di+1] = srcData[si+1]
data[di+2] = srcData[si+2]
data[di+3] = srcData[si+3]
}
}
selW, selH := bx2-bx1+1, by2-by1+1
if r.shiftHeld && selW != selH {
if selW < selH {
selH = selW
} else {
selW = selH
}
}
r.drawBorder(data, stride, w, h, bx1, by1, selW, selH, format)
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
}
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
if r.selection.dragging {
return
}
style := LoadOverlayStyle()
const charW, charH, padding, itemSpacing = 8, 12, 12, 24
cursorLabel := "hide"
if !r.showCapturedCursor {
cursorLabel = "show"
}
items := []struct{ key, desc string }{
{"Space/Enter", "capture"},
{"P", cursorLabel + " cursor"},
{"Esc", "cancel"},
}
totalW := 0
for i, item := range items {
totalW += len(item.key)*(charW+1) + 4 + len(item.desc)*(charW+1)
if i < len(items)-1 {
totalW += itemSpacing
}
}
hudW := totalW + padding*2
hudH := charH + padding*2
hudX := (bufW - hudW) / 2
hudY := bufH - hudH - 20
r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format)
tx, ty := hudX+padding, hudY+padding
for i, item := range items {
r.drawText(data, stride, bufW, bufH, tx, ty, item.key,
style.AccentR, style.AccentG, style.AccentB, format)
tx += len(item.key) * (charW + 1)
r.drawText(data, stride, bufW, bufH, tx, ty, " "+item.desc,
style.TextR, style.TextG, style.TextB, format)
tx += (1 + len(item.desc)) * (charW + 1)
if i < len(items)-1 {
tx += itemSpacing
}
}
}
func (r *RegionSelector) drawBorder(data []byte, stride, bufW, bufH, x, y, w, h int, format uint32) {
const thickness = 2
for i := 0; i < thickness; i++ {
r.drawHLine(data, stride, bufW, bufH, x-i, y-i, w+2*i, format)
r.drawHLine(data, stride, bufW, bufH, x-i, y+h+i-1, w+2*i, format)
r.drawVLine(data, stride, bufW, bufH, x-i, y-i, h+2*i, format)
r.drawVLine(data, stride, bufW, bufH, x+w+i-1, y-i, h+2*i, format)
}
}
func (r *RegionSelector) drawHLine(data []byte, stride, bufW, bufH, x, y, length int, _ uint32) {
if y < 0 || y >= bufH {
return
}
rowOff := y * stride
for i := 0; i < length; i++ {
px := x + i
if px < 0 || px >= bufW {
continue
}
off := rowOff + px*4
if off+3 >= len(data) {
continue
}
data[off], data[off+1], data[off+2], data[off+3] = 255, 255, 255, 255
}
}
func (r *RegionSelector) drawVLine(data []byte, stride, bufW, bufH, x, y, length int, _ uint32) {
if x < 0 || x >= bufW {
return
}
for i := 0; i < length; i++ {
py := y + i
if py < 0 || py >= bufH {
continue
}
off := py*stride + x*4
if off+3 >= len(data) {
continue
}
data[off], data[off+1], data[off+2], data[off+3] = 255, 255, 255, 255
}
}
func (r *RegionSelector) drawDimensions(data []byte, stride, bufW, bufH, x, y, w, h int, format uint32) {
text := fmt.Sprintf("%dx%d", w, h)
const charW, charH = 8, 12
textW := len(text) * (charW + 1)
textH := charH
tx := x + (w-textW)/2
ty := y + h + 8
if ty+textH > bufH {
ty = y - textH - 8
}
tx = clamp(tx, 0, bufW-textW)
r.fillRect(data, stride, bufW, bufH, tx-4, ty-2, textW+8, textH+4, 0, 0, 0, 200, format)
r.drawText(data, stride, bufW, bufH, tx, ty, text, 255, 255, 255, format)
}
func (r *RegionSelector) fillRect(data []byte, stride, bufW, bufH, x, y, w, h int, cr, cg, cb, ca uint8, format uint32) {
alpha := float64(ca) / 255.0
invAlpha := 1.0 - alpha
c0, c2 := cb, cr
if format == uint32(FormatABGR8888) || format == uint32(FormatXBGR8888) {
c0, c2 = cr, cb
}
for py := y; py < y+h && py < bufH; py++ {
if py < 0 {
continue
}
for px := x; px < x+w && px < bufW; px++ {
if px < 0 {
continue
}
off := py*stride + px*4
if off+3 >= len(data) {
continue
}
data[off+0] = uint8(float64(data[off+0])*invAlpha + float64(c0)*alpha)
data[off+1] = uint8(float64(data[off+1])*invAlpha + float64(cg)*alpha)
data[off+2] = uint8(float64(data[off+2])*invAlpha + float64(c2)*alpha)
data[off+3] = 255
}
}
}
func (r *RegionSelector) drawText(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32) {
for i, ch := range text {
r.drawChar(data, stride, bufW, bufH, x+i*9, y, ch, cr, cg, cb, format)
}
}
func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32) {
glyph, ok := fontGlyphs[ch]
if !ok {
return
}
c0, c2 := cb, cr
if format == uint32(FormatABGR8888) || format == uint32(FormatXBGR8888) {
c0, c2 = cr, cb
}
for row := 0; row < 12; row++ {
py := y + row
if py < 0 || py >= bufH {
continue
}
bits := glyph[row]
for col := 0; col < 8; col++ {
if (bits & (1 << (7 - col))) == 0 {
continue
}
px := x + col
if px < 0 || px >= bufW {
continue
}
off := py*stride + px*4
if off+3 >= len(data) {
continue
}
data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255
}
}
}
func clamp(v, lo, hi int) int {
switch {
case v < lo:
return lo
case v > hi:
return hi
default:
return v
}
}
+939
View File
@@ -0,0 +1,939 @@
package screenshot
import (
"fmt"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type WaylandOutput struct {
wlOutput *client.Output
globalName uint32
name string
x, y int32
width int32
height int32
scale int32
fractionalScale float64
transform int32
}
type CaptureResult struct {
Buffer *ShmBuffer
Region Region
YInverted bool
Format uint32
}
type Screenshoter struct {
config Config
display *client.Display
registry *client.Registry
ctx *client.Context
compositor *client.Compositor
shm *client.Shm
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
outputs map[uint32]*WaylandOutput
outputsMu sync.Mutex
}
func New(config Config) *Screenshoter {
return &Screenshoter{
config: config,
outputs: make(map[uint32]*WaylandOutput),
}
}
func (s *Screenshoter) Run() (*CaptureResult, error) {
if err := s.connect(); err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
defer s.cleanup()
if err := s.setupRegistry(); err != nil {
return nil, fmt.Errorf("registry setup: %w", err)
}
if err := s.roundtrip(); err != nil {
return nil, fmt.Errorf("roundtrip: %w", err)
}
if s.screencopy == nil {
return nil, fmt.Errorf("compositor does not support wlr-screencopy-unstable-v1")
}
if err := s.roundtrip(); err != nil {
return nil, fmt.Errorf("roundtrip: %w", err)
}
switch s.config.Mode {
case ModeLastRegion:
return s.captureLastRegion()
case ModeRegion:
return s.captureRegion()
case ModeWindow:
return s.captureWindow()
case ModeOutput:
return s.captureOutput(s.config.OutputName)
case ModeFullScreen:
return s.captureFullScreen()
case ModeAllScreens:
return s.captureAllScreens()
default:
return s.captureRegion()
}
}
func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
lastRegion := GetLastRegion()
if lastRegion.IsEmpty() {
return s.captureRegion()
}
output := s.findOutputForRegion(lastRegion)
if output == nil {
return s.captureRegion()
}
return s.captureRegionOnOutput(output, lastRegion)
}
func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
selector := NewRegionSelector(s)
result, cancelled, err := selector.Run()
if err != nil {
return nil, fmt.Errorf("region selection: %w", err)
}
if cancelled || result == nil {
return nil, nil
}
if err := SaveLastRegion(result.Region); err != nil {
log.Debug("failed to save last region", "err", err)
}
return result, nil
}
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
geom, err := GetActiveWindow()
if err != nil {
return nil, err
}
region := Region{
X: geom.X,
Y: geom.Y,
Width: geom.Width,
Height: geom.Height,
}
var output *WaylandOutput
if geom.Output != "" {
output = s.findOutputByName(geom.Output)
}
if output == nil {
output = s.findOutputForRegion(region)
}
if output == nil {
return nil, fmt.Errorf("could not find output for window")
}
switch DetectCompositor() {
case CompositorHyprland:
return s.captureAndCrop(output, region)
case CompositorDWL:
return s.captureDWLWindow(output, region, geom.Scale)
default:
return s.captureRegionOnOutput(output, region)
}
}
func (s *Screenshoter) captureDWLWindow(output *WaylandOutput, region Region, dwlScale float64) (*CaptureResult, error) {
result, err := s.captureWholeOutput(output)
if err != nil {
return nil, err
}
scale := dwlScale
if scale <= 0 {
scale = float64(result.Buffer.Width) / float64(output.width)
}
if scale <= 0 {
scale = 1.0
}
localX := int(float64(region.X) * scale)
localY := int(float64(region.Y) * scale)
if localX >= result.Buffer.Width {
localX = localX % result.Buffer.Width
}
if localY >= result.Buffer.Height {
localY = localY % result.Buffer.Height
}
w := int(float64(region.Width) * scale)
h := int(float64(region.Height) * scale)
if localY+h > result.Buffer.Height && h <= result.Buffer.Height {
localY = result.Buffer.Height - h
if localY < 0 {
localY = 0
}
}
if localX+w > result.Buffer.Width && w <= result.Buffer.Width {
localX = result.Buffer.Width - w
if localX < 0 {
localX = 0
}
}
if localX < 0 {
w += localX
localX = 0
}
if localY < 0 {
h += localY
localY = 0
}
if localX+w > result.Buffer.Width {
w = result.Buffer.Width - localX
}
if localY+h > result.Buffer.Height {
h = result.Buffer.Height - localY
}
if w <= 0 || h <= 0 {
result.Buffer.Close()
return nil, fmt.Errorf("window not visible on output")
}
cropped, err := CreateShmBuffer(w, h, w*4)
if err != nil {
result.Buffer.Close()
return nil, fmt.Errorf("create crop buffer: %w", err)
}
srcData := result.Buffer.Data()
dstData := cropped.Data()
for y := 0; y < h; y++ {
srcY := localY + y
if result.YInverted {
srcY = result.Buffer.Height - 1 - (localY + y)
}
if srcY < 0 || srcY >= result.Buffer.Height {
continue
}
dstY := y
if result.YInverted {
dstY = h - 1 - y
}
for x := 0; x < w; x++ {
srcX := localX + x
if srcX < 0 || srcX >= result.Buffer.Width {
continue
}
si := srcY*result.Buffer.Stride + srcX*4
di := dstY*cropped.Stride + x*4
if si+3 >= len(srcData) || di+3 >= len(dstData) {
continue
}
dstData[di+0] = srcData[si+0]
dstData[di+1] = srcData[si+1]
dstData[di+2] = srcData[si+2]
dstData[di+3] = srcData[si+3]
}
}
result.Buffer.Close()
cropped.Format = PixelFormat(result.Format)
return &CaptureResult{
Buffer: cropped,
Region: region,
YInverted: false,
Format: result.Format,
}, nil
}
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
output := s.findFocusedOutput()
if output == nil {
s.outputsMu.Lock()
for _, o := range s.outputs {
output = o
break
}
s.outputsMu.Unlock()
}
if output == nil {
return nil, fmt.Errorf("no output available")
}
return s.captureWholeOutput(output)
}
func (s *Screenshoter) captureOutput(name string) (*CaptureResult, error) {
s.outputsMu.Lock()
var output *WaylandOutput
for _, o := range s.outputs {
if o.name == name {
output = o
break
}
}
s.outputsMu.Unlock()
if output == nil {
return nil, fmt.Errorf("output %q not found", name)
}
return s.captureWholeOutput(output)
}
func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
s.outputsMu.Lock()
outputs := make([]*WaylandOutput, 0, len(s.outputs))
for _, o := range s.outputs {
outputs = append(outputs, o)
}
s.outputsMu.Unlock()
if len(outputs) == 0 {
return nil, fmt.Errorf("no outputs available")
}
if len(outputs) == 1 {
return s.captureWholeOutput(outputs[0])
}
// Capture all outputs first to get actual buffer sizes
type capturedOutput struct {
output *WaylandOutput
result *CaptureResult
physX int
physY int
}
captured := make([]capturedOutput, 0, len(outputs))
var minX, minY, maxX, maxY int
first := true
for _, output := range outputs {
result, err := s.captureWholeOutput(output)
if err != nil {
log.Warn("failed to capture output", "name", output.name, "err", err)
continue
}
outX, outY := output.x, output.y
scale := float64(output.scale)
if DetectCompositor() == CompositorHyprland {
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
outX, outY = hx, hy
}
if s := GetHyprlandMonitorScale(output.name); s > 0 {
scale = s
}
}
if scale <= 0 {
scale = 1.0
}
physX := int(float64(outX) * scale)
physY := int(float64(outY) * scale)
captured = append(captured, capturedOutput{
output: output,
result: result,
physX: physX,
physY: physY,
})
right := physX + result.Buffer.Width
bottom := physY + result.Buffer.Height
if first {
minX, minY = physX, physY
maxX, maxY = right, bottom
first = false
continue
}
if physX < minX {
minX = physX
}
if physY < minY {
minY = physY
}
if right > maxX {
maxX = right
}
if bottom > maxY {
maxY = bottom
}
}
if len(captured) == 0 {
return nil, fmt.Errorf("failed to capture any outputs")
}
if len(captured) == 1 {
return captured[0].result, nil
}
totalW := maxX - minX
totalH := maxY - minY
compositeStride := totalW * 4
composite, err := CreateShmBuffer(totalW, totalH, compositeStride)
if err != nil {
for _, c := range captured {
c.result.Buffer.Close()
}
return nil, fmt.Errorf("create composite buffer: %w", err)
}
composite.Clear()
var format uint32
for _, c := range captured {
if format == 0 {
format = c.result.Format
}
s.blitBuffer(composite, c.result.Buffer, c.physX-minX, c.physY-minY, c.result.YInverted)
c.result.Buffer.Close()
}
return &CaptureResult{
Buffer: composite,
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
Format: format,
}, nil
}
func (s *Screenshoter) blitBuffer(dst, src *ShmBuffer, dstX, dstY int, yInverted bool) {
srcData := src.Data()
dstData := dst.Data()
for srcY := 0; srcY < src.Height; srcY++ {
actualSrcY := srcY
if yInverted {
actualSrcY = src.Height - 1 - srcY
}
dy := dstY + srcY
if dy < 0 || dy >= dst.Height {
continue
}
srcRowOff := actualSrcY * src.Stride
dstRowOff := dy * dst.Stride
for srcX := 0; srcX < src.Width; srcX++ {
dx := dstX + srcX
if dx < 0 || dx >= dst.Width {
continue
}
si := srcRowOff + srcX*4
di := dstRowOff + dx*4
if si+3 >= len(srcData) || di+3 >= len(dstData) {
continue
}
dstData[di+0] = srcData[si+0]
dstData[di+1] = srcData[si+1]
dstData[di+2] = srcData[si+2]
dstData[di+3] = srcData[si+3]
}
}
}
func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult, error) {
cursor := int32(0)
if s.config.IncludeCursor {
cursor = 1
}
frame, err := s.screencopy.CaptureOutput(cursor, output.wlOutput)
if err != nil {
return nil, fmt.Errorf("capture output: %w", err)
}
return s.processFrame(frame, Region{
X: output.x,
Y: output.y,
Width: output.width,
Height: output.height,
Output: output.name,
})
}
func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*CaptureResult, error) {
result, err := s.captureWholeOutput(output)
if err != nil {
return nil, err
}
outX, outY := output.x, output.y
scale := float64(output.scale)
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
outX, outY = hx, hy
}
if s := GetHyprlandMonitorScale(output.name); s > 0 {
scale = s
}
if scale <= 0 {
scale = 1.0
}
localX := int(float64(region.X-outX) * scale)
localY := int(float64(region.Y-outY) * scale)
w := int(float64(region.Width) * scale)
h := int(float64(region.Height) * scale)
cropped, err := CreateShmBuffer(w, h, w*4)
if err != nil {
result.Buffer.Close()
return nil, fmt.Errorf("create crop buffer: %w", err)
}
srcData := result.Buffer.Data()
dstData := cropped.Data()
for y := 0; y < h; y++ {
srcY := localY + y
if result.YInverted {
srcY = result.Buffer.Height - 1 - (localY + y)
}
if srcY < 0 || srcY >= result.Buffer.Height {
continue
}
dstY := y
if result.YInverted {
dstY = h - 1 - y
}
for x := 0; x < w; x++ {
srcX := localX + x
if srcX < 0 || srcX >= result.Buffer.Width {
continue
}
si := srcY*result.Buffer.Stride + srcX*4
di := dstY*cropped.Stride + x*4
if si+3 >= len(srcData) || di+3 >= len(dstData) {
continue
}
dstData[di+0] = srcData[si+0]
dstData[di+1] = srcData[si+1]
dstData[di+2] = srcData[si+2]
dstData[di+3] = srcData[si+3]
}
}
result.Buffer.Close()
cropped.Format = PixelFormat(result.Format)
return &CaptureResult{
Buffer: cropped,
Region: region,
YInverted: false,
Format: result.Format,
}, nil
}
func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
scale := output.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(output.name)
}
if scale <= 0 {
scale = float64(output.scale)
}
if scale <= 0 {
scale = 1.0
}
localX := int32(float64(region.X-output.x) * scale)
localY := int32(float64(region.Y-output.y) * scale)
w := int32(float64(region.Width) * scale)
h := int32(float64(region.Height) * scale)
if DetectCompositor() == CompositorDWL {
scaledOutW := int32(float64(output.width) * scale)
scaledOutH := int32(float64(output.height) * scale)
if localX >= scaledOutW {
localX = localX % scaledOutW
}
if localY >= scaledOutH {
localY = localY % scaledOutH
}
if localX+w > scaledOutW {
w = scaledOutW - localX
}
if localY+h > scaledOutH {
h = scaledOutH - localY
}
if localX < 0 {
w += localX
localX = 0
}
if localY < 0 {
h += localY
localY = 0
}
}
cursor := int32(0)
if s.config.IncludeCursor {
cursor = 1
}
frame, err := s.screencopy.CaptureOutputRegion(cursor, output.wlOutput, localX, localY, w, h)
if err != nil {
return nil, fmt.Errorf("capture region: %w", err)
}
return s.processFrame(frame, region)
}
func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1, region Region) (*CaptureResult, error) {
var buf *ShmBuffer
var pool *client.ShmPool
var wlBuf *client.Buffer
var format PixelFormat
var yInverted bool
ready := false
failed := false
frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) {
var err error
buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
if err != nil {
log.Error("failed to create buffer", "err", err)
return
}
format = PixelFormat(e.Format)
buf.Format = format
})
frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) {
yInverted = (e.Flags & 1) != 0
})
frame.SetBufferDoneHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferDoneEvent) {
if buf == nil {
return
}
var err error
pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil {
log.Error("failed to create pool", "err", err)
return
}
wlBuf, err = pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), uint32(format))
if err != nil {
pool.Destroy()
pool = nil
log.Error("failed to create wl_buffer", "err", err)
return
}
if err := frame.Copy(wlBuf); err != nil {
log.Error("failed to copy frame", "err", err)
}
})
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
ready = true
})
frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) {
failed = true
})
for !ready && !failed {
if err := s.ctx.Dispatch(); err != nil {
frame.Destroy()
return nil, fmt.Errorf("dispatch: %w", err)
}
}
frame.Destroy()
if wlBuf != nil {
wlBuf.Destroy()
}
if pool != nil {
pool.Destroy()
}
if failed {
if buf != nil {
buf.Close()
}
return nil, fmt.Errorf("frame capture failed")
}
return &CaptureResult{
Buffer: buf,
Region: region,
YInverted: yInverted,
Format: uint32(format),
}, nil
}
func (s *Screenshoter) findOutputByName(name string) *WaylandOutput {
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
for _, o := range s.outputs {
if o.name == name {
return o
}
}
return nil
}
func (s *Screenshoter) findOutputForRegion(region Region) *WaylandOutput {
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
cx := region.X + region.Width/2
cy := region.Y + region.Height/2
for _, o := range s.outputs {
x, y, w, h := o.x, o.y, o.width, o.height
if DetectCompositor() == CompositorHyprland {
if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok {
x, y, w, h = hx, hy, hw, hh
}
}
if cx >= x && cx < x+w && cy >= y && cy < y+h {
return o
}
}
for _, o := range s.outputs {
x, y, w, h := o.x, o.y, o.width, o.height
if DetectCompositor() == CompositorHyprland {
if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok {
x, y, w, h = hx, hy, hw, hh
}
}
if region.X >= x && region.X < x+w &&
region.Y >= y && region.Y < y+h {
return o
}
}
return nil
}
func (s *Screenshoter) findFocusedOutput() *WaylandOutput {
if mon := GetFocusedMonitor(); mon != "" {
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
for _, o := range s.outputs {
if o.name == mon {
return o
}
}
}
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
for _, o := range s.outputs {
return o
}
return nil
}
func (s *Screenshoter) connect() error {
display, err := client.Connect("")
if err != nil {
return err
}
s.display = display
s.ctx = display.Context()
return nil
}
func (s *Screenshoter) roundtrip() error {
return wlhelpers.Roundtrip(s.display, s.ctx)
}
func (s *Screenshoter) setupRegistry() error {
registry, err := s.display.GetRegistry()
if err != nil {
return err
}
s.registry = registry
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
s.handleGlobal(e)
})
registry.SetGlobalRemoveHandler(func(e client.RegistryGlobalRemoveEvent) {
s.outputsMu.Lock()
delete(s.outputs, e.Name)
s.outputsMu.Unlock()
})
return nil
}
func (s *Screenshoter) handleGlobal(e client.RegistryGlobalEvent) {
switch e.Interface {
case client.CompositorInterfaceName:
comp := client.NewCompositor(s.ctx)
if err := s.registry.Bind(e.Name, e.Interface, e.Version, comp); err == nil {
s.compositor = comp
}
case client.ShmInterfaceName:
shm := client.NewShm(s.ctx)
if err := s.registry.Bind(e.Name, e.Interface, e.Version, shm); err == nil {
s.shm = shm
}
case client.OutputInterfaceName:
output := client.NewOutput(s.ctx)
version := e.Version
if version > 4 {
version = 4
}
if err := s.registry.Bind(e.Name, e.Interface, version, output); err == nil {
s.outputsMu.Lock()
s.outputs[e.Name] = &WaylandOutput{
wlOutput: output,
globalName: e.Name,
scale: 1,
fractionalScale: 1.0,
}
s.outputsMu.Unlock()
s.setupOutputHandlers(e.Name, output)
}
case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName:
sc := wlr_screencopy.NewZwlrScreencopyManagerV1(s.ctx)
version := e.Version
if version > 3 {
version = 3
}
if err := s.registry.Bind(e.Name, e.Interface, version, sc); err == nil {
s.screencopy = sc
}
}
}
func (s *Screenshoter) setupOutputHandlers(name uint32, output *client.Output) {
output.SetGeometryHandler(func(e client.OutputGeometryEvent) {
s.outputsMu.Lock()
if o, ok := s.outputs[name]; ok {
o.x, o.y = e.X, e.Y
o.transform = int32(e.Transform)
}
s.outputsMu.Unlock()
})
output.SetModeHandler(func(e client.OutputModeEvent) {
if e.Flags&uint32(client.OutputModeCurrent) == 0 {
return
}
s.outputsMu.Lock()
if o, ok := s.outputs[name]; ok {
o.width, o.height = e.Width, e.Height
}
s.outputsMu.Unlock()
})
output.SetScaleHandler(func(e client.OutputScaleEvent) {
s.outputsMu.Lock()
if o, ok := s.outputs[name]; ok {
o.scale = e.Factor
o.fractionalScale = float64(e.Factor)
}
s.outputsMu.Unlock()
})
output.SetNameHandler(func(e client.OutputNameEvent) {
s.outputsMu.Lock()
if o, ok := s.outputs[name]; ok {
o.name = e.Name
}
s.outputsMu.Unlock()
})
}
func (s *Screenshoter) cleanup() {
if s.screencopy != nil {
s.screencopy.Destroy()
}
if s.display != nil {
s.ctx.Close()
}
}
func (s *Screenshoter) GetOutputs() []*WaylandOutput {
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
out := make([]*WaylandOutput, 0, len(s.outputs))
for _, o := range s.outputs {
out = append(out, o)
}
return out
}
func ListOutputs() ([]Output, error) {
sc := New(DefaultConfig())
if err := sc.connect(); err != nil {
return nil, err
}
defer sc.cleanup()
if err := sc.setupRegistry(); err != nil {
return nil, err
}
if err := sc.roundtrip(); err != nil {
return nil, err
}
if err := sc.roundtrip(); err != nil {
return nil, err
}
sc.outputsMu.Lock()
defer sc.outputsMu.Unlock()
result := make([]Output, 0, len(sc.outputs))
for _, o := range sc.outputs {
result = append(result, Output{
Name: o.name,
X: o.x,
Y: o.y,
Width: o.width,
Height: o.height,
Scale: o.scale,
})
}
return result, nil
}
+18
View File
@@ -0,0 +1,18 @@
package screenshot
import "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm"
type PixelFormat = shm.PixelFormat
const (
FormatARGB8888 = shm.FormatARGB8888
FormatXRGB8888 = shm.FormatXRGB8888
FormatABGR8888 = shm.FormatABGR8888
FormatXBGR8888 = shm.FormatXBGR8888
)
type ShmBuffer = shm.Buffer
func CreateShmBuffer(width, height, stride int) (*ShmBuffer, error) {
return shm.CreateBuffer(width, height, stride)
}
+65
View File
@@ -0,0 +1,65 @@
package screenshot
import (
"encoding/json"
"os"
"path"
"path/filepath"
)
type PersistentState struct {
LastRegion Region `json:"last_region"`
}
func getStateFilePath() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = path.Join(os.Getenv("HOME"), ".cache")
}
return filepath.Join(cacheDir, "dms", "screenshot-state.json")
}
func LoadState() (*PersistentState, error) {
path := getStateFilePath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &PersistentState{}, nil
}
return nil, err
}
var state PersistentState
if err := json.Unmarshal(data, &state); err != nil {
return &PersistentState{}, nil
}
return &state, nil
}
func SaveState(state *PersistentState) error {
path := getStateFilePath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
func GetLastRegion() Region {
state, err := LoadState()
if err != nil {
return Region{}
}
return state.LastRegion
}
func SaveLastRegion(r Region) error {
state, _ := LoadState()
state.LastRegion = r
return SaveState(state)
}
+127
View File
@@ -0,0 +1,127 @@
package screenshot
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
)
type ThemeColors struct {
Background string `json:"surface"`
OnSurface string `json:"on_surface"`
Primary string `json:"primary"`
}
type ColorScheme struct {
Dark ThemeColors `json:"dark"`
Light ThemeColors `json:"light"`
}
type ColorsFile struct {
Colors ColorScheme `json:"colors"`
}
var cachedStyle *OverlayStyle
func LoadOverlayStyle() OverlayStyle {
if cachedStyle != nil {
return *cachedStyle
}
style := DefaultOverlayStyle
colors := loadColorsFile()
if colors == nil {
cachedStyle = &style
return style
}
theme := &colors.Dark
if isLightMode() {
theme = &colors.Light
}
if bg, ok := parseHexColor(theme.Background); ok {
style.BackgroundR, style.BackgroundG, style.BackgroundB = bg[0], bg[1], bg[2]
}
if text, ok := parseHexColor(theme.OnSurface); ok {
style.TextR, style.TextG, style.TextB = text[0], text[1], text[2]
}
if accent, ok := parseHexColor(theme.Primary); ok {
style.AccentR, style.AccentG, style.AccentB = accent[0], accent[1], accent[2]
}
cachedStyle = &style
return style
}
func loadColorsFile() *ColorScheme {
path := getColorsFilePath()
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var file ColorsFile
if err := json.Unmarshal(data, &file); err != nil {
return nil
}
return &file.Colors
}
func getColorsFilePath() string {
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
home := os.Getenv("HOME")
if home == "" {
return ""
}
cacheDir = filepath.Join(home, ".cache")
}
return filepath.Join(cacheDir, "DankMaterialShell", "dms-colors.json")
}
func isLightMode() bool {
out, err := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme").Output()
if err != nil {
return false
}
scheme := strings.TrimSpace(string(out))
switch scheme {
case "'prefer-light'", "'default'":
return true
}
return false
}
func parseHexColor(hex string) ([3]uint8, bool) {
hex = strings.TrimPrefix(hex, "#")
if len(hex) != 6 {
return [3]uint8{}, false
}
var r, g, b uint8
for i, ptr := range []*uint8{&r, &g, &b} {
val := 0
for j := 0; j < 2; j++ {
c := hex[i*2+j]
val *= 16
switch {
case c >= '0' && c <= '9':
val += int(c - '0')
case c >= 'a' && c <= 'f':
val += int(c - 'a' + 10)
case c >= 'A' && c <= 'F':
val += int(c - 'A' + 10)
default:
return [3]uint8{}, false
}
}
*ptr = uint8(val)
}
return [3]uint8{r, g, b}, true
}
+68
View File
@@ -0,0 +1,68 @@
package screenshot
type Mode int
const (
ModeRegion Mode = iota
ModeWindow
ModeFullScreen
ModeAllScreens
ModeOutput
ModeLastRegion
)
type Format int
const (
FormatPNG Format = iota
FormatJPEG
FormatPPM
)
type Region struct {
X int32 `json:"x"`
Y int32 `json:"y"`
Width int32 `json:"width"`
Height int32 `json:"height"`
Output string `json:"output,omitempty"`
}
func (r Region) IsEmpty() bool {
return r.Width <= 0 || r.Height <= 0
}
type Output struct {
Name string
X, Y int32
Width int32
Height int32
Scale int32
}
type Config struct {
Mode Mode
OutputName string
IncludeCursor bool
Format Format
Quality int
OutputDir string
Filename string
Clipboard bool
SaveFile bool
Notify bool
Stdout bool
}
func DefaultConfig() Config {
return Config{
Mode: ModeRegion,
IncludeCursor: false,
Format: FormatPNG,
Quality: 90,
OutputDir: "",
Filename: "",
Clipboard: true,
SaveFile: true,
Notify: true,
}
}
+12 -4
View File
@@ -40,6 +40,10 @@ func (b *DDCBackend) scanI2CDevices() error {
return b.scanI2CDevicesInternal(false)
}
func (b *DDCBackend) ForceRescan() error {
return b.scanI2CDevicesInternal(true)
}
func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
b.scanMutex.Lock()
defer b.scanMutex.Unlock()
@@ -64,10 +68,6 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
activeBuses[i] = true
id := fmt.Sprintf("ddc:i2c-%d", i)
if _, exists := b.devices.Load(id); exists {
continue
}
dev, err := b.probeDDCDevice(i)
if err != nil || dev == nil {
continue
@@ -261,8 +261,16 @@ func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) er
busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
if _, err := os.Stat(busPath); os.IsNotExist(err) {
b.devices.Delete(id)
log.Debugf("removed stale DDC device %s (bus no longer exists)", id)
return fmt.Errorf("device disconnected: %s", id)
}
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
if err != nil {
b.devices.Delete(id)
log.Debugf("removed DDC device %s (open failed: %v)", id, err)
return fmt.Errorf("open i2c device: %w", err)
}
defer syscall.Close(fd)
@@ -89,6 +89,13 @@ func (m *Manager) initDDC() {
func (m *Manager) Rescan() {
log.Debug("Rescanning brightness devices...")
if m.ddcReady && m.ddcBackend != nil {
if err := m.ddcBackend.ForceRescan(); err != nil {
log.Debugf("DDC force rescan failed: %v", err)
}
}
m.updateState()
}
+54 -8
View File
@@ -5,13 +5,18 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/pilebones/go-udev/netlink"
)
type UdevMonitor struct {
stop chan struct{}
stop chan struct{}
rescanMutex sync.Mutex
rescanTimer *time.Timer
rescanPending bool
}
func NewUdevMonitor(manager *Manager) *UdevMonitor {
@@ -34,10 +39,8 @@ func (m *UdevMonitor) run(manager *Manager) {
matcher := &netlink.RuleDefinitions{
Rules: []netlink.RuleDefinition{
{Env: map[string]string{"SUBSYSTEM": "backlight"}},
// ! TODO: most drivers dont emit this for leds?
// ! inotify brightness_hw_changed works, but thn some devices dont do that...
// ! So for now the GUI just shows OSDs for leds, without reflecting actual HW value
// {Env: map[string]string{"SUBSYSTEM": "leds"}},
{Env: map[string]string{"SUBSYSTEM": "drm"}},
{Env: map[string]string{"SUBSYSTEM": "i2c"}},
},
}
if err := matcher.Compile(); err != nil {
@@ -49,7 +52,7 @@ func (m *UdevMonitor) run(manager *Manager) {
errs := make(chan error)
conn.Monitor(events, errs, matcher)
log.Info("Udev monitor started for backlight/leds events")
log.Info("Udev monitor started for backlight/drm/i2c events")
for {
select {
@@ -75,11 +78,54 @@ func (m *UdevMonitor) handleEvent(manager *Manager, event netlink.UEvent) {
sysname := filepath.Base(devpath)
action := string(event.Action)
switch subsystem {
case "drm", "i2c":
m.handleDisplayEvent(manager, action, subsystem, sysname)
case "backlight":
m.handleBacklightEvent(manager, action, sysname)
}
}
func (m *UdevMonitor) handleDisplayEvent(manager *Manager, action, subsystem, sysname string) {
switch action {
case "add", "remove", "change":
log.Debugf("Udev %s event: %s:%s - queueing DDC rescan", action, subsystem, sysname)
m.debouncedRescan(manager)
}
}
func (m *UdevMonitor) debouncedRescan(manager *Manager) {
m.rescanMutex.Lock()
defer m.rescanMutex.Unlock()
m.rescanPending = true
if m.rescanTimer != nil {
m.rescanTimer.Reset(2 * time.Second)
return
}
m.rescanTimer = time.AfterFunc(2*time.Second, func() {
m.rescanMutex.Lock()
pending := m.rescanPending
m.rescanPending = false
m.rescanMutex.Unlock()
if !pending {
return
}
log.Debug("Executing debounced DDC rescan")
manager.Rescan()
})
}
func (m *UdevMonitor) handleBacklightEvent(manager *Manager, action, sysname string) {
switch action {
case "change":
m.handleChange(manager, subsystem, sysname)
m.handleChange(manager, "backlight", sysname)
case "add", "remove":
log.Debugf("Udev %s event: %s:%s - triggering rescan", action, subsystem, sysname)
log.Debugf("Udev %s event: backlight:%s - triggering rescan", action, sysname)
manager.Rescan()
}
}
+91
View File
@@ -0,0 +1,91 @@
package server
import (
"context"
"net"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type MatugenQueueResult struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
func handleMatugenQueue(conn net.Conn, req models.Request) {
getString := func(key string) string {
if v, ok := req.Params[key].(string); ok {
return v
}
return ""
}
getBool := func(key string, def bool) bool {
if v, ok := req.Params[key].(bool); ok {
return v
}
return def
}
opts := matugen.Options{
StateDir: getString("stateDir"),
ShellDir: getString("shellDir"),
ConfigDir: getString("configDir"),
Kind: getString("kind"),
Value: getString("value"),
Mode: getString("mode"),
IconTheme: getString("iconTheme"),
MatugenType: getString("matugenType"),
RunUserTemplates: getBool("runUserTemplates", true),
StockColors: getString("stockColors"),
SyncModeWithPortal: getBool("syncModeWithPortal", false),
TerminalsAlwaysDark: getBool("terminalsAlwaysDark", false),
}
wait := getBool("wait", true)
queue := matugen.GetQueue()
resultCh := queue.Submit(opts)
if !wait {
models.Respond(conn, req.ID, MatugenQueueResult{
Success: true,
Message: "queued",
})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
select {
case result := <-resultCh:
if result.Error != nil {
if result.Error == context.Canceled {
models.Respond(conn, req.ID, MatugenQueueResult{
Success: false,
Message: "cancelled",
})
return
}
models.RespondError(conn, req.ID, result.Error.Error())
return
}
models.Respond(conn, req.ID, MatugenQueueResult{
Success: true,
Message: "completed",
})
case <-ctx.Done():
models.RespondError(conn, req.ID, "timeout waiting for theme generation")
}
}
func handleMatugenStatus(conn net.Conn, req models.Request) {
queue := matugen.GetQueue()
models.Respond(conn, req.ID, map[string]bool{
"running": queue.IsRunning(),
"pending": queue.HasPending(),
})
}
+4
View File
@@ -215,6 +215,10 @@ func RouteRequest(conn net.Conn, req models.Request) {
models.Respond(conn, req.ID, info)
case "subscribe":
handleSubscribe(conn, req)
case "matugen.queue":
handleMatugenQueue(conn, req)
case "matugen.status":
handleMatugenStatus(conn, req)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
+26
View File
@@ -0,0 +1,26 @@
package client
import wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
func Roundtrip(display *wlclient.Display, ctx *wlclient.Context) error {
callback, err := display.Sync()
if err != nil {
return err
}
done := make(chan struct{})
callback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) {
close(done)
})
for {
select {
case <-done:
return nil
default:
if err := ctx.Dispatch(); err != nil {
return err
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package shm
import (
"fmt"
"golang.org/x/sys/unix"
)
type PixelFormat uint32
const (
FormatARGB8888 PixelFormat = 0
FormatXRGB8888 PixelFormat = 1
FormatABGR8888 PixelFormat = 0x34324241
FormatXBGR8888 PixelFormat = 0x34324258
)
type Buffer struct {
fd int
data []byte
size int
Width int
Height int
Stride int
Format PixelFormat
}
func CreateBuffer(width, height, stride int) (*Buffer, error) {
size := stride * height
fd, err := unix.MemfdCreate("dms-shm", 0)
if err != nil {
return nil, fmt.Errorf("memfd_create: %w", err)
}
if err := unix.Ftruncate(fd, int64(size)); err != nil {
unix.Close(fd)
return nil, fmt.Errorf("ftruncate: %w", err)
}
data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
unix.Close(fd)
return nil, fmt.Errorf("mmap: %w", err)
}
return &Buffer{
fd: fd,
data: data,
size: size,
Width: width,
Height: height,
Stride: stride,
}, nil
}
func (b *Buffer) Fd() int { return b.fd }
func (b *Buffer) Size() int { return b.size }
func (b *Buffer) Data() []byte { return b.data }
func (b *Buffer) Close() error {
var firstErr error
if b.data != nil {
if err := unix.Munmap(b.data); err != nil && firstErr == nil {
firstErr = fmt.Errorf("munmap: %w", err)
}
b.data = nil
}
if b.fd >= 0 {
if err := unix.Close(b.fd); err != nil && firstErr == nil {
firstErr = fmt.Errorf("close: %w", err)
}
b.fd = -1
}
return firstErr
}
func (b *Buffer) GetPixelRGBA(x, y int) (r, g, b2, a uint8) {
if x < 0 || x >= b.Width || y < 0 || y >= b.Height {
return
}
off := y*b.Stride + x*4
if off+3 >= len(b.data) {
return
}
return b.data[off+2], b.data[off+1], b.data[off], b.data[off+3]
}
func (b *Buffer) GetPixelBGRA(x, y int) (b2, g, r, a uint8) {
if x < 0 || x >= b.Width || y < 0 || y >= b.Height {
return
}
off := y*b.Stride + x*4
if off+3 >= len(b.data) {
return
}
return b.data[off], b.data[off+1], b.data[off+2], b.data[off+3]
}
func (b *Buffer) ConvertBGRAtoRGBA() {
for y := 0; y < b.Height; y++ {
rowOff := y * b.Stride
for x := 0; x < b.Width; x++ {
off := rowOff + x*4
if off+3 >= len(b.data) {
continue
}
b.data[off], b.data[off+2] = b.data[off+2], b.data[off]
}
}
}
func (b *Buffer) FlipVertical() {
tmp := make([]byte, b.Stride)
for y := 0; y < b.Height/2; y++ {
topOff := y * b.Stride
botOff := (b.Height - 1 - y) * b.Stride
copy(tmp, b.data[topOff:topOff+b.Stride])
copy(b.data[topOff:topOff+b.Stride], b.data[botOff:botOff+b.Stride])
copy(b.data[botOff:botOff+b.Stride], tmp)
}
}
func (b *Buffer) Clear() {
for i := range b.data {
b.data[i] = 0
}
}
func (b *Buffer) CopyFrom(src *Buffer) {
copy(b.data, src.data)
}
+28 -27
View File
@@ -113,7 +113,7 @@ func (i *Display) GetRegistry() (*Registry, error) {
}
func (i *Display) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -224,15 +224,16 @@ func (i *Display) Dispatch(opcode uint32, fd int, data []byte) {
i.errorHandler(e)
case 1:
if i.deleteIdHandler == nil {
return
}
var e DisplayDeleteIdEvent
l := 0
e.Id = Uint32(data[l : l+4])
l += 4
i.deleteIdHandler(e)
i.Context().DeleteID(e.Id)
if i.deleteIdHandler != nil {
i.deleteIdHandler(e)
}
}
}
@@ -326,7 +327,7 @@ func (i *Registry) Bind(name uint32, iface string, version uint32, id Proxy) err
}
func (i *Registry) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -433,7 +434,7 @@ func NewCallback(ctx *Context) *Callback {
}
func (i *Callback) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -529,7 +530,7 @@ func (i *Compositor) CreateRegion() (*Region, error) {
}
func (i *Compositor) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -619,7 +620,7 @@ func (i *ShmPool) CreateBuffer(offset, width, height, stride int32, format uint3
// buffers that have been created from this pool
// are gone.
func (i *ShmPool) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -735,7 +736,7 @@ func (i *Shm) CreatePool(fd int, size int32) (*ShmPool, error) {
//
// Objects created via this interface remain unaffected.
func (i *Shm) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -1642,7 +1643,7 @@ func NewBuffer(ctx *Context) *Buffer {
//
// For possible side-effects to a surface, see wl_surface.attach.
func (i *Buffer) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -1803,7 +1804,7 @@ func (i *DataOffer) Receive(mimeType string, fd int) error {
//
// Destroy the data offer.
func (i *DataOffer) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 2
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -2120,7 +2121,7 @@ func (i *DataSource) Offer(mimeType string) error {
//
// Destroy the data source.
func (i *DataSource) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -2540,7 +2541,7 @@ func (i *DataDevice) SetSelection(source *DataSource, serial uint32) error {
//
// This request destroys the data device.
func (i *DataDevice) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 2
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -2859,7 +2860,7 @@ func (i *DataDeviceManager) GetDataDevice(seat *Seat) (*DataDevice, error) {
}
func (i *DataDeviceManager) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -3000,7 +3001,7 @@ func (i *Shell) GetShellSurface(surface *Surface) (*ShellSurface, error) {
}
func (i *Shell) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -3421,7 +3422,7 @@ func (i *ShellSurface) SetClass(class string) error {
}
func (i *ShellSurface) Destroy() error {
i.Context().Unregister(i)
i.MarkZombie()
return nil
}
@@ -3798,7 +3799,7 @@ func NewSurface(ctx *Context) *Surface {
//
// Deletes the surface and invalidates its object ID.
func (i *Surface) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -4618,7 +4619,7 @@ func (i *Seat) GetTouch() (*Touch, error) {
// Using this request a client can tell the server that it is not going to
// use the seat object anymore.
func (i *Seat) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 3
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -4920,7 +4921,7 @@ func (i *Pointer) SetCursor(serial uint32, surface *Surface, hotspotX, hotspotY
// This request destroys the pointer proxy object, so clients must not call
// wl_pointer_destroy() after using this request.
func (i *Pointer) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -5685,7 +5686,7 @@ func NewKeyboard(ctx *Context) *Keyboard {
// Release : release the keyboard object
func (i *Keyboard) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -6091,7 +6092,7 @@ func NewTouch(ctx *Context) *Touch {
// Release : release the touch object
func (i *Touch) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -6406,7 +6407,7 @@ func NewOutput(ctx *Context) *Output {
// Using this request a client can tell the server that it is not going to
// use the output object anymore.
func (i *Output) Release() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -6923,7 +6924,7 @@ func NewRegion(ctx *Context) *Region {
//
// Destroy the region. This will invalidate the object ID.
func (i *Region) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -7057,7 +7058,7 @@ func NewSubcompositor(ctx *Context) *Subcompositor {
// protocol object anymore. This does not affect any other
// objects, wl_subsurface objects included.
func (i *Subcompositor) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -7280,7 +7281,7 @@ func NewSubsurface(ctx *Context) *Subsurface {
// wl_subcompositor.get_subsurface request. The wl_surface's association
// to the parent is deleted. The wl_surface is unmapped immediately.
func (i *Subsurface) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
@@ -7499,7 +7500,7 @@ func NewFixes(ctx *Context) *Fixes {
// Destroy : destroys this object
func (i *Fixes) Destroy() error {
defer i.Context().Unregister(i)
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
+15 -2
View File
@@ -1,5 +1,7 @@
package client
import "sync/atomic"
type Dispatcher interface {
Dispatch(opcode uint32, fd int, data []byte)
}
@@ -9,11 +11,14 @@ type Proxy interface {
SetContext(ctx *Context)
ID() uint32
SetID(id uint32)
IsZombie() bool
MarkZombie()
}
type BaseProxy struct {
ctx *Context
id uint32
ctx *Context
id uint32
zombie atomic.Bool
}
func (p *BaseProxy) ID() uint32 {
@@ -31,3 +36,11 @@ func (p *BaseProxy) Context() *Context {
func (p *BaseProxy) SetContext(ctx *Context) {
p.ctx = ctx
}
func (p *BaseProxy) IsZombie() bool {
return p.zombie.Load()
}
func (p *BaseProxy) MarkZombie() {
p.zombie.Store(true)
}
@@ -32,6 +32,10 @@ func (ctx *Context) Unregister(p Proxy) {
ctx.objects.Delete(p.ID())
}
func (ctx *Context) DeleteID(id uint32) {
ctx.objects.Delete(id)
}
func (ctx *Context) GetProxy(id uint32) Proxy {
if val, ok := ctx.objects.Load(id); ok {
return val
@@ -72,7 +76,11 @@ func (ctx *Context) GetDispatch() func() error {
return func() error {
proxy, ok := ctx.objects.Load(senderID)
if !ok {
return fmt.Errorf("%w (senderID=%d)", ErrDispatchSenderNotFound, senderID)
return nil // Proxy already deleted via delete_id, silently ignore
}
if proxy.IsZombie() {
return nil // Zombie proxy, discard late events
}
sender, ok := proxy.(Dispatcher)
+2 -2
View File
@@ -7,7 +7,7 @@
}: let
cfg = config.programs.dankMaterialShell;
in {
qmlPath = "${dmsPkgs.dankMaterialShell}/etc/xdg/quickshell/dms";
qmlPath = "${dmsPkgs.dms-shell}/share/quickshell/dms";
packages =
[
@@ -19,7 +19,7 @@ in {
pkgs.libsForQt5.qt5ct
pkgs.kdePackages.qt6ct
dmsPkgs.dmsCli
dmsPkgs.dms-shell
]
++ lib.optional cfg.enableSystemMonitoring dmsPkgs.dgop
++ lib.optionals cfg.enableClipboard [pkgs.cliphist pkgs.wl-clipboard]
+4 -2
View File
@@ -20,7 +20,7 @@
"--command"
cfg.compositor.name
"-p"
"${dmsPkgs.dankMaterialShell}/etc/xdg/quickshell/dms"
"${dmsPkgs.dms-shell}/share/quickshell/dms"
]
++ lib.optionals (cfg.compositor.customConfig != "") [
"-C"
@@ -61,7 +61,9 @@ in {
'';
};
quickshell = {
package = lib.mkPackageOption pkgs "quickshell" {};
package = lib.mkPackageOption dmsPkgs "quickshell" {
extraDescription = "The quickshell package to use (defaults to be built from source, in the commit 26531f due to unreleased features used by DMS).";
};
};
logs.save = lib.mkEnableOption "saving logs from DMS greeter to file";
logs.path = lib.mkOption {
+4 -4
View File
@@ -4,13 +4,13 @@
lib,
dmsPkgs,
...
}: let
} @ args: let
cfg = config.programs.dankMaterialShell;
jsonFormat = pkgs.formats.json {};
common = import ./common.nix {inherit config pkgs lib dmsPkgs;};
in {
imports = [
./options.nix
(import ./options.nix args)
(lib.mkRemovedOptionModule ["programs" "dankMaterialShell" "enableNightMode"] "Night mode is now always available.")
(lib.mkRenamedOptionModule ["programs" "dankMaterialShell" "enableSystemd"] ["programs" "dankMaterialShell" "systemd" "enable"])
];
@@ -66,7 +66,7 @@ in {
};
Service = {
ExecStart = lib.getExe dmsPkgs.dmsCli + " run --session";
ExecStart = lib.getExe dmsPkgs.dms-shell + " run --session";
Restart = "on-failure";
};
@@ -89,6 +89,6 @@ in {
}
];
home.packages = common.packages ++ [dmsPkgs.dankMaterialShell];
home.packages = common.packages;
};
}
+5 -5
View File
@@ -4,17 +4,17 @@
lib,
dmsPkgs,
...
}: let
} @ args: let
cfg = config.programs.dankMaterialShell;
common = import ./common.nix {inherit config pkgs lib dmsPkgs;};
in {
imports = [
./options.nix
(import ./options.nix args)
];
config = lib.mkIf cfg.enable
{
environment.etc."xdg/quickshell/dms".source = "${dmsPkgs.dankMaterialShell}/etc/xdg/quickshell/dms";
environment.etc."xdg/quickshell/dms".source = "${dmsPkgs.dms-shell}/share/quickshell/dms";
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
description = "DankMaterialShell";
@@ -26,11 +26,11 @@ in {
restartTriggers = lib.optional cfg.systemd.restartIfChanged common.qmlPath;
serviceConfig = {
ExecStart = lib.getExe dmsPkgs.dmsCli + " run --session";
ExecStart = lib.getExe dmsPkgs.dms-shell + " run --session";
Restart = "on-failure";
};
};
environment.systemPackages = [cfg.quickshell.package dmsPkgs.dankMaterialShell] ++ common.packages;
environment.systemPackages = [cfg.quickshell.package] ++ common.packages;
};
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
pkgs,
lib,
dmsPkgs,
...
}: let
inherit (lib) types;
@@ -62,7 +62,9 @@ in {
description = "Add needed dependencies to have system sound support";
};
quickshell = {
package = lib.mkPackageOption pkgs "quickshell" {};
package = lib.mkPackageOption dmsPkgs "quickshell" {
extraDescription = "The quickshell package to use (defaults to be built from source, in the commit 26531f due to unreleased features used by DMS).";
};
};
};
}
+193 -164
View File
@@ -139,6 +139,20 @@ esac
OBS_PROJECT="${OBS_BASE_PROJECT}:${PROJECT}"
echo "==> Target: $OBS_PROJECT / $PACKAGE"
# Detect if this is a manual run or automated
IS_MANUAL=false
if [[ -n "${REBUILD_RELEASE:-}" ]]; then
IS_MANUAL=true
echo "==> Manual rebuild detected (REBUILD_RELEASE=$REBUILD_RELEASE)"
elif [[ -n "${FORCE_REBUILD:-}" ]] && [[ "${FORCE_REBUILD}" == "true" ]]; then
IS_MANUAL=true
echo "==> Manual workflow trigger detected (FORCE_REBUILD=true)"
elif [[ -z "${GITHUB_ACTIONS:-}" ]] && [[ -z "${CI:-}" ]]; then
IS_MANUAL=true
echo "==> Local/manual run detected (not in CI)"
fi
if [[ "$UPLOAD_DEBIAN" == true && "$UPLOAD_OPENSUSE" == true ]]; then
echo "==> Distributions: Debian + OpenSUSE"
elif [[ "$UPLOAD_DEBIAN" == true ]]; then
@@ -192,9 +206,20 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ -f "distro/opensuse/$PACKAGE.spec" ]];
if [[ "$NEW_VERSION" == "$OLD_VERSION" ]]; then
if [[ "$OLD_RELEASE" =~ ^([0-9]+) ]]; then
BASE_RELEASE="${BASH_REMATCH[1]}"
NEXT_RELEASE=$((BASE_RELEASE + 1))
echo " - Detected rebuild of same version $NEW_VERSION (release $OLD_RELEASE -> $NEXT_RELEASE)"
sed -i "s/^Release:[[:space:]]*${NEW_RELEASE}%{?dist}/Release: ${NEXT_RELEASE}%{?dist}/" "$WORK_DIR/$PACKAGE.spec"
if [[ "$IS_MANUAL" == true ]]; then
NEXT_RELEASE=$((BASE_RELEASE + 1))
echo " - Detected rebuild of same version $NEW_VERSION (release $OLD_RELEASE -> $NEXT_RELEASE)"
sed -i "s/^Release:[[:space:]]*${NEW_RELEASE}%{?dist}/Release: ${NEXT_RELEASE}%{?dist}/" "$WORK_DIR/$PACKAGE.spec"
else
echo " - Detected same version $NEW_VERSION (release $OLD_RELEASE). Not a manual run, skipping update."
# For automated runs with no version change, we should stop here to avoid unnecessary rebuilds
# However, we need to check if we are also updating Debian, or if this script is expected to continue.
# If this is OpenSUSE only run, we can exit.
if [[ "$UPLOAD_DEBIAN" == false ]]; then
echo "✅ No changes needed for OpenSUSE (not manual). Exiting."
exit 0
fi
fi
fi
else
echo " - New version detected: $OLD_VERSION -> $NEW_VERSION (keeping release $NEW_RELEASE)"
@@ -643,175 +668,182 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ "$SOURCE_FORMAT" == *"native"* ]] && [[
CHANGELOG_BASE=$(echo "$CHANGELOG_VERSION" | sed 's/ppa[0-9]*$//')
OLD_DSC_BASE=$(echo "$OLD_DSC_VERSION" | sed 's/ppa[0-9]*$//')
if [[ -n "$OLD_DSC_VERSION" ]] && [[ "$OLD_DSC_BASE" == "$CHANGELOG_BASE" ]] && [[ "$IS_MANUAL" == true ]]; then
echo "==> Detected rebuild of same base version $CHANGELOG_BASE, incrementing version"
if [[ -n "$OLD_DSC_VERSION" ]] && [[ "$OLD_DSC_BASE" == "$CHANGELOG_BASE" ]]; then
if [[ "$IS_MANUAL" == true ]]; then
echo "==> Detected rebuild of same base version $CHANGELOG_BASE, incrementing version"
if [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)\+git$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
NEW_VERSION="${BASE_VERSION}+gitppa1"
echo " Adding PPA number: $CHANGELOG_VERSION -> $NEW_VERSION"
elif [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)ppa([0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
PPA_NUM="${BASH_REMATCH[2]}"
NEW_PPA_NUM=$((PPA_NUM + 1))
NEW_VERSION="${BASE_VERSION}ppa${NEW_PPA_NUM}"
echo " Incrementing PPA number: $CHANGELOG_VERSION -> $NEW_VERSION"
elif [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)\+git([0-9]+)(\.[a-f0-9]+)?(ppa([0-9]+))?$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
GIT_NUM="${BASH_REMATCH[2]}"
GIT_HASH="${BASH_REMATCH[3]}"
PPA_NUM="${BASH_REMATCH[5]}"
# Check if old DSC has ppa suffix even if changelog doesn't
if [[ -z "$PPA_NUM" ]] && [[ "$OLD_DSC_VERSION" =~ ppa([0-9]+)$ ]]; then
OLD_PPA_NUM="${BASH_REMATCH[1]}"
NEW_PPA_NUM=$((OLD_PPA_NUM + 1))
NEW_VERSION="${BASE_VERSION}+git${GIT_NUM}${GIT_HASH}ppa${NEW_PPA_NUM}"
echo " Incrementing PPA number from old DSC: $OLD_DSC_VERSION -> $NEW_VERSION"
elif [[ -n "$PPA_NUM" ]]; then
NEW_PPA_NUM=$((PPA_NUM + 1))
NEW_VERSION="${BASE_VERSION}+git${GIT_NUM}${GIT_HASH}ppa${NEW_PPA_NUM}"
echo " Incrementing PPA number: $CHANGELOG_VERSION -> $NEW_VERSION"
else
NEW_VERSION="${BASE_VERSION}+git${GIT_NUM}${GIT_HASH}ppa1"
if [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)\+git$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
NEW_VERSION="${BASE_VERSION}+gitppa1"
echo " Adding PPA number: $CHANGELOG_VERSION -> $NEW_VERSION"
fi
elif [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)(-([0-9]+))?$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
NEW_VERSION="${BASE_VERSION}ppa1"
echo " Warning: Native format cannot have Debian revision, converting to PPA format: $CHANGELOG_VERSION -> $NEW_VERSION"
else
NEW_VERSION="${CHANGELOG_VERSION}ppa1"
echo " Warning: Could not parse version format, appending ppa1: $CHANGELOG_VERSION -> $NEW_VERSION"
fi
if [[ -z "$SOURCE_DIR" ]] || [[ ! -d "$SOURCE_DIR" ]] || [[ ! -d "$SOURCE_DIR/debian" ]]; then
echo " Error: Source directory with debian/ not found for version increment"
exit 1
fi
SOURCE_CHANGELOG="$SOURCE_DIR/debian/changelog"
if [[ ! -f "$SOURCE_CHANGELOG" ]]; then
echo " Error: Changelog not found in source directory: $SOURCE_CHANGELOG"
exit 1
fi
REPO_CHANGELOG="$REPO_ROOT/distro/debian/$PACKAGE/debian/changelog"
TEMP_CHANGELOG=$(mktemp)
{
echo "$PACKAGE ($NEW_VERSION) unstable; urgency=medium"
echo ""
echo " * Rebuild to fix repository metadata issues"
echo ""
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $(date -R)"
echo ""
if [[ -f "$REPO_CHANGELOG" ]]; then
OLD_ENTRY_START=$(grep -n "^$PACKAGE (" "$REPO_CHANGELOG" | sed -n '2p' | cut -d: -f1)
if [[ -n "$OLD_ENTRY_START" ]]; then
tail -n +$OLD_ENTRY_START "$REPO_CHANGELOG"
elif [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)ppa([0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
PPA_NUM="${BASH_REMATCH[2]}"
NEW_PPA_NUM=$((PPA_NUM + 1))
NEW_VERSION="${BASE_VERSION}ppa${NEW_PPA_NUM}"
echo " Incrementing PPA number: $CHANGELOG_VERSION -> $NEW_VERSION"
elif [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)\+git([0-9]+)(\.[a-f0-9]+)?(ppa([0-9]+))?$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
GIT_NUM="${BASH_REMATCH[2]}"
GIT_HASH="${BASH_REMATCH[3]}"
PPA_NUM="${BASH_REMATCH[5]}"
# Check if old DSC has ppa suffix even if changelog doesn't
if [[ -z "$PPA_NUM" ]] && [[ "$OLD_DSC_VERSION" =~ ppa([0-9]+)$ ]]; then
OLD_PPA_NUM="${BASH_REMATCH[1]}"
NEW_PPA_NUM=$((OLD_PPA_NUM + 1))
NEW_VERSION="${BASE_VERSION}+git${GIT_NUM}${GIT_HASH}ppa${NEW_PPA_NUM}"
echo " Incrementing PPA number from old DSC: $OLD_DSC_VERSION -> $NEW_VERSION"
elif [[ -n "$PPA_NUM" ]]; then
NEW_PPA_NUM=$((PPA_NUM + 1))
NEW_VERSION="${BASE_VERSION}+git${GIT_NUM}${GIT_HASH}ppa${NEW_PPA_NUM}"
echo " Incrementing PPA number: $CHANGELOG_VERSION -> $NEW_VERSION"
else
NEW_VERSION="${BASE_VERSION}+git${GIT_NUM}${GIT_HASH}ppa1"
echo " Adding PPA number: $CHANGELOG_VERSION -> $NEW_VERSION"
fi
elif [[ "$CHANGELOG_VERSION" =~ ^([0-9.]+)(-([0-9]+))?$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
NEW_VERSION="${BASE_VERSION}ppa1"
echo " Warning: Native format cannot have Debian revision, converting to PPA format: $CHANGELOG_VERSION -> $NEW_VERSION"
else
NEW_VERSION="${CHANGELOG_VERSION}ppa1"
echo " Warning: Could not parse version format, appending ppa1: $CHANGELOG_VERSION -> $NEW_VERSION"
fi
} > "$TEMP_CHANGELOG"
cp "$TEMP_CHANGELOG" "$SOURCE_CHANGELOG"
rm -f "$TEMP_CHANGELOG"
CHANGELOG_VERSION="$NEW_VERSION"
VERSION="$NEW_VERSION"
COMBINED_TARBALL="${PACKAGE}_${VERSION}.tar.gz"
for old_tarball in "${PACKAGE}"_*.tar.gz; do
if [[ -f "$old_tarball" ]] && [[ "$old_tarball" != "${PACKAGE}_${NEW_VERSION}.tar.gz" ]]; then
echo " Removing old tarball from OBS: $old_tarball"
osc rm -f "$old_tarball" 2>/dev/null || rm -f "$old_tarball"
if [[ -z "$SOURCE_DIR" ]] || [[ ! -d "$SOURCE_DIR" ]] || [[ ! -d "$SOURCE_DIR/debian" ]]; then
echo " Error: Source directory with debian/ not found for version increment"
exit 1
fi
done
if [[ "$PACKAGE" == "dms" ]] && [[ -f "$WORK_DIR/dms-source.tar.gz" ]]; then
echo " Recreating dms-source.tar.gz with new directory name for incremented version"
EXPECTED_SOURCE_DIR="DankMaterialShell-${NEW_VERSION}"
TEMP_SOURCE_DIR=$(mktemp -d)
cd "$TEMP_SOURCE_DIR"
tar -xzf "$WORK_DIR/dms-source.tar.gz" 2>/dev/null || tar -xJf "$WORK_DIR/dms-source.tar.gz" 2>/dev/null || tar -xjf "$WORK_DIR/dms-source.tar.gz" 2>/dev/null
EXTRACTED=$(find . -maxdepth 1 -type d -name "DankMaterialShell-*" | head -1)
if [[ -n "$EXTRACTED" ]] && [[ "$EXTRACTED" != "./$EXPECTED_SOURCE_DIR" ]]; then
echo " Renaming $EXTRACTED to $EXPECTED_SOURCE_DIR"
mv "$EXTRACTED" "$EXPECTED_SOURCE_DIR"
rm -f "$WORK_DIR/dms-source.tar.gz"
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/dms-source.tar.gz" "$EXPECTED_SOURCE_DIR"
ROOT_DIR=$(tar -tf "$WORK_DIR/dms-source.tar.gz" | head -1 | cut -d/ -f1)
if [[ "$ROOT_DIR" != "$EXPECTED_SOURCE_DIR" ]]; then
echo " Error: Recreated tarball has wrong root directory: $ROOT_DIR (expected $EXPECTED_SOURCE_DIR)"
exit 1
SOURCE_CHANGELOG="$SOURCE_DIR/debian/changelog"
if [[ ! -f "$SOURCE_CHANGELOG" ]]; then
echo " Error: Changelog not found in source directory: $SOURCE_CHANGELOG"
exit 1
fi
REPO_CHANGELOG="$REPO_ROOT/distro/debian/$PACKAGE/debian/changelog"
TEMP_CHANGELOG=$(mktemp)
{
echo "$PACKAGE ($NEW_VERSION) unstable; urgency=medium"
echo ""
echo " * Rebuild to fix repository metadata issues"
echo ""
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $(date -R)"
echo ""
if [[ -f "$REPO_CHANGELOG" ]]; then
OLD_ENTRY_START=$(grep -n "^$PACKAGE (" "$REPO_CHANGELOG" | sed -n '2p' | cut -d: -f1)
if [[ -n "$OLD_ENTRY_START" ]]; then
tail -n +$OLD_ENTRY_START "$REPO_CHANGELOG"
fi
fi
} > "$TEMP_CHANGELOG"
cp "$TEMP_CHANGELOG" "$SOURCE_CHANGELOG"
rm -f "$TEMP_CHANGELOG"
CHANGELOG_VERSION="$NEW_VERSION"
VERSION="$NEW_VERSION"
COMBINED_TARBALL="${PACKAGE}_${VERSION}.tar.gz"
for old_tarball in "${PACKAGE}"_*.tar.gz; do
if [[ -f "$old_tarball" ]] && [[ "$old_tarball" != "${PACKAGE}_${NEW_VERSION}.tar.gz" ]]; then
echo " Removing old tarball from OBS: $old_tarball"
osc rm -f "$old_tarball" 2>/dev/null || rm -f "$old_tarball"
fi
done
if [[ "$PACKAGE" == "dms" ]] && [[ -f "$WORK_DIR/dms-source.tar.gz" ]]; then
echo " Recreating dms-source.tar.gz with new directory name for incremented version"
EXPECTED_SOURCE_DIR="DankMaterialShell-${NEW_VERSION}"
TEMP_SOURCE_DIR=$(mktemp -d)
cd "$TEMP_SOURCE_DIR"
tar -xzf "$WORK_DIR/dms-source.tar.gz" 2>/dev/null || tar -xJf "$WORK_DIR/dms-source.tar.gz" 2>/dev/null || tar -xjf "$WORK_DIR/dms-source.tar.gz" 2>/dev/null
EXTRACTED=$(find . -maxdepth 1 -type d -name "DankMaterialShell-*" | head -1)
if [[ -n "$EXTRACTED" ]] && [[ "$EXTRACTED" != "./$EXPECTED_SOURCE_DIR" ]]; then
echo " Renaming $EXTRACTED to $EXPECTED_SOURCE_DIR"
mv "$EXTRACTED" "$EXPECTED_SOURCE_DIR"
rm -f "$WORK_DIR/dms-source.tar.gz"
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/dms-source.tar.gz" "$EXPECTED_SOURCE_DIR"
ROOT_DIR=$(tar -tf "$WORK_DIR/dms-source.tar.gz" | head -1 | cut -d/ -f1)
if [[ "$ROOT_DIR" != "$EXPECTED_SOURCE_DIR" ]]; then
echo " Error: Recreated tarball has wrong root directory: $ROOT_DIR (expected $EXPECTED_SOURCE_DIR)"
exit 1
fi
fi
cd "$REPO_ROOT"
rm -rf "$TEMP_SOURCE_DIR"
fi
cd "$REPO_ROOT"
rm -rf "$TEMP_SOURCE_DIR"
fi
echo " Recreating tarball with new version: $COMBINED_TARBALL"
if [[ -n "$SOURCE_DIR" ]] && [[ -d "$SOURCE_DIR" ]] && [[ -d "$SOURCE_DIR/debian" ]]; then
if [[ "$PACKAGE" == "dms" ]]; then
cd "$(dirname "$SOURCE_DIR")"
CURRENT_DIR=$(basename "$SOURCE_DIR")
EXPECTED_DIR="DankMaterialShell-${NEW_VERSION}"
if [[ "$CURRENT_DIR" != "$EXPECTED_DIR" ]]; then
echo " Renaming directory from $CURRENT_DIR to $EXPECTED_DIR to match debian/rules"
if [[ -d "$CURRENT_DIR" ]]; then
mv "$CURRENT_DIR" "$EXPECTED_DIR"
SOURCE_DIR="$(pwd)/$EXPECTED_DIR"
else
echo " Warning: Source directory $CURRENT_DIR not found, extracting from existing tarball"
OLD_TARBALL=$(ls "${PACKAGE}"_*.tar.gz 2>/dev/null | head -1)
if [[ -f "$OLD_TARBALL" ]]; then
EXTRACT_DIR=$(mktemp -d)
cd "$EXTRACT_DIR"
tar -xzf "$WORK_DIR/$OLD_TARBALL"
EXTRACTED_DIR=$(find . -maxdepth 1 -type d -name "DankMaterialShell-*" | head -1)
if [[ -n "$EXTRACTED_DIR" ]] && [[ "$EXTRACTED_DIR" != "./$EXPECTED_DIR" ]]; then
mv "$EXTRACTED_DIR" "$EXPECTED_DIR"
if [[ -f "$EXPECTED_DIR/debian/changelog" ]]; then
ACTUAL_VER=$(grep -m1 "^$PACKAGE" "$EXPECTED_DIR/debian/changelog" 2>/dev/null | sed 's/.*(\([^)]*\)).*/\1/')
if [[ "$ACTUAL_VER" != "$NEW_VERSION" ]]; then
echo " Updating changelog version in extracted directory"
REPO_CHANGELOG="$REPO_ROOT/distro/debian/$PACKAGE/debian/changelog"
TEMP_CHANGELOG=$(mktemp)
{
echo "$PACKAGE ($NEW_VERSION) unstable; urgency=medium"
echo ""
echo " * Rebuild to fix repository metadata issues"
echo ""
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $(date -R)"
echo ""
if [[ -f "$REPO_CHANGELOG" ]]; then
OLD_ENTRY_START=$(grep -n "^$PACKAGE (" "$REPO_CHANGELOG" | sed -n '2p' | cut -d: -f1)
if [[ -n "$OLD_ENTRY_START" ]]; then
tail -n +$OLD_ENTRY_START "$REPO_CHANGELOG"
fi
fi
} > "$TEMP_CHANGELOG"
cp "$TEMP_CHANGELOG" "$EXPECTED_DIR/debian/changelog"
rm -f "$TEMP_CHANGELOG"
fi
fi
SOURCE_DIR="$(pwd)/$EXPECTED_DIR"
cd "$REPO_ROOT"
else
echo " Error: Could not extract or find source directory"
rm -rf "$EXTRACT_DIR"
exit 1
fi
echo " Recreating tarball with new version: $COMBINED_TARBALL"
if [[ -n "$SOURCE_DIR" ]] && [[ -d "$SOURCE_DIR" ]] && [[ -d "$SOURCE_DIR/debian" ]]; then
if [[ "$PACKAGE" == "dms" ]]; then
cd "$(dirname "$SOURCE_DIR")"
CURRENT_DIR=$(basename "$SOURCE_DIR")
EXPECTED_DIR="DankMaterialShell-${NEW_VERSION}"
if [[ "$CURRENT_DIR" != "$EXPECTED_DIR" ]]; then
echo " Renaming directory from $CURRENT_DIR to $EXPECTED_DIR to match debian/rules"
if [[ -d "$CURRENT_DIR" ]]; then
mv "$CURRENT_DIR" "$EXPECTED_DIR"
SOURCE_DIR="$(pwd)/$EXPECTED_DIR"
else
echo " Error: No existing tarball found to extract"
exit 1
echo " Warning: Source directory $CURRENT_DIR not found, extracting from existing tarball"
OLD_TARBALL=$(ls "${PACKAGE}"_*.tar.gz 2>/dev/null | head -1)
if [[ -f "$OLD_TARBALL" ]]; then
EXTRACT_DIR=$(mktemp -d)
cd "$EXTRACT_DIR"
tar -xzf "$WORK_DIR/$OLD_TARBALL"
EXTRACTED_DIR=$(find . -maxdepth 1 -type d -name "DankMaterialShell-*" | head -1)
if [[ -n "$EXTRACTED_DIR" ]] && [[ "$EXTRACTED_DIR" != "./$EXPECTED_DIR" ]]; then
mv "$EXTRACTED_DIR" "$EXPECTED_DIR"
if [[ -f "$EXPECTED_DIR/debian/changelog" ]]; then
ACTUAL_VER=$(grep -m1 "^$PACKAGE" "$EXPECTED_DIR/debian/changelog" 2>/dev/null | sed 's/.*(\([^)]*\)).*/\1/')
if [[ "$ACTUAL_VER" != "$NEW_VERSION" ]]; then
echo " Updating changelog version in extracted directory"
REPO_CHANGELOG="$REPO_ROOT/distro/debian/$PACKAGE/debian/changelog"
TEMP_CHANGELOG=$(mktemp)
{
echo "$PACKAGE ($NEW_VERSION) unstable; urgency=medium"
echo ""
echo " * Rebuild to fix repository metadata issues"
echo ""
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $(date -R)"
echo ""
if [[ -f "$REPO_CHANGELOG" ]]; then
OLD_ENTRY_START=$(grep -n "^$PACKAGE (" "$REPO_CHANGELOG" | sed -n '2p' | cut -d: -f1)
if [[ -n "$OLD_ENTRY_START" ]]; then
tail -n +$OLD_ENTRY_START "$REPO_CHANGELOG"
fi
fi
} > "$TEMP_CHANGELOG"
cp "$TEMP_CHANGELOG" "$EXPECTED_DIR/debian/changelog"
rm -f "$TEMP_CHANGELOG"
fi
fi
SOURCE_DIR="$(pwd)/$EXPECTED_DIR"
cd "$REPO_ROOT"
else
echo " Error: Could not extract or find source directory"
rm -rf "$EXTRACT_DIR"
exit 1
fi
fi
fi
fi
fi
rm -f "$WORK_DIR/$COMBINED_TARBALL"
echo " Creating combined tarball: $COMBINED_TARBALL"
cd "$(dirname "$SOURCE_DIR")"
TARBALL_BASE=$(basename "$SOURCE_DIR")
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$COMBINED_TARBALL" "$TARBALL_BASE"
cd "$REPO_ROOT"
fi
cd "$(dirname "$SOURCE_DIR")"
TARBALL_BASE=$(basename "$SOURCE_DIR")
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$COMBINED_TARBALL" "$TARBALL_BASE"
cd "$WORK_DIR"
else
echo "==> Detected same version. Not a manual run, skipping Debian version increment."
echo "✅ No changes needed for Debian. Exiting."
exit 0
fi
TARBALL_SIZE=$(stat -c%s "$WORK_DIR/$COMBINED_TARBALL" 2>/dev/null || stat -f%z "$WORK_DIR/$COMBINED_TARBALL" 2>/dev/null)
TARBALL_MD5=$(md5sum "$WORK_DIR/$COMBINED_TARBALL" | cut -d' ' -f1)
@@ -852,10 +884,7 @@ Files:
$TARBALL_MD5 $TARBALL_SIZE $COMBINED_TARBALL
EOF
echo " - Updated changelog and recreated tarball with version $NEW_VERSION"
else
echo " Error: Source directory not found, cannot recreate tarball"
exit 1
fi
fi
fi
+30 -2
View File
@@ -103,6 +103,22 @@ if [ "$CHANGELOG_SERIES" != "$UBUNTU_SERIES" ] && [ "$CHANGELOG_SERIES" != "UNRE
warn "Consider updating changelog with: dch -r '' -D $UBUNTU_SERIES"
fi
# Check if this is a manual run or automated
IS_MANUAL=false
if [[ -n "${REBUILD_RELEASE:-}" ]]; then
IS_MANUAL=true
echo "==> Manual rebuild detected (REBUILD_RELEASE=$REBUILD_RELEASE)"
elif [[ -n "${FORCE_REBUILD:-}" ]] && [[ "${FORCE_REBUILD}" == "true" ]]; then
IS_MANUAL=true
echo "==> Manual workflow trigger detected (FORCE_REBUILD=true)"
elif [[ "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then
IS_MANUAL=true
echo "==> Manual workflow trigger detected (workflow_dispatch)"
elif [[ -z "${GITHUB_ACTIONS:-}" ]] && [[ -z "${CI:-}" ]]; then
IS_MANUAL=true
echo "==> Local/manual run detected (not in CI)"
fi
# Detect package type and update version automatically
cd "$PACKAGE_DIR"
@@ -274,7 +290,13 @@ if [ "$IS_GIT_PACKAGE" = true ] && [ -n "$GIT_REPO" ]; then
ESCAPED_BASE=$(echo "$BASE_VERSION" | sed 's/\./\\./g' | sed 's/+/\\+/g')
if [[ "$CURRENT_VERSION" =~ ^${ESCAPED_BASE}ppa([0-9]+)$ ]]; then
PPA_NUM=$((BASH_REMATCH[1] + 1))
info "Detected rebuild of same commit (current: $CURRENT_VERSION), incrementing PPA number to $PPA_NUM"
if [[ "$IS_MANUAL" == true ]]; then
info "Detected rebuild of same commit (current: $CURRENT_VERSION), incrementing PPA number to $PPA_NUM"
else
info "Detected rebuild of same commit (current: $CURRENT_VERSION). Not a manual run, skipping."
success "No changes needed (commit matches)."
exit 0
fi
else
info "New commit or first build, using PPA number $PPA_NUM"
fi
@@ -427,7 +449,13 @@ elif [ -n "$GIT_REPO" ]; then
ESCAPED_BASE=$(echo "$BASE_VERSION" | sed 's/\./\\./g' | sed 's/-/\\-/g')
if [[ "$CURRENT_VERSION" =~ ^${ESCAPED_BASE}ppa([0-9]+)$ ]]; then
PPA_NUM=$((BASH_REMATCH[1] + 1))
info "Detected rebuild of same version (current: $CURRENT_VERSION), incrementing PPA number to $PPA_NUM"
if [[ "$IS_MANUAL" == true ]]; then
info "Detected rebuild of same version (current: $CURRENT_VERSION), incrementing PPA number to $PPA_NUM"
else
info "Detected rebuild of same version (current: $CURRENT_VERSION). Not a manual run, skipping."
success "No changes needed (version matches)."
exit 0
fi
else
info "New version or first build, using PPA number $PPA_NUM"
fi
+3 -2
View File
@@ -84,8 +84,9 @@ fi
CHANGES_FILE=$(find "$PARENT_DIR" -maxdepth 1 -name "${PACKAGE_NAME}_*_source.changes" -type f | sort -V | tail -1)
if [ -z "$CHANGES_FILE" ]; then
error "Changes file not found in $PARENT_DIR"
exit 1
warn "Changes file not found in $PARENT_DIR"
warn "Assuming build was skipped (no changes needed) and exiting successfully."
exit 0
fi
info "Found changes file: $CHANGES_FILE"
Generated
+29 -7
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1762435535,
"narHash": "sha256-QhzRn7pYN35IFpKjjxJAj3GPJECuC+VLhoGem3ezycc=",
"lastModified": 1762835999,
"narHash": "sha256-UykYGrGFOFTmDpKTLNxj1wvd1gbDG4TkqLNSbV0TYwk=",
"owner": "AvengeMedia",
"repo": "dgop",
"rev": "6cf638dde818f9f8a2e26d0243179c43cb3458d7",
"rev": "799301991cd5dcea9b64245f9d500dcc76615653",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1762363567,
"narHash": "sha256-YRqMDEtSMbitIMj+JLpheSz0pwEr0Rmy5mC7myl17xs=",
"lastModified": 1764950072,
"narHash": "sha256-BmPWzogsG2GsXZtlT+MTcAWeDK5hkbGRZTeZNW42fwA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ae814fd3904b621d8ab97418f1d0f2eb0d3716f4",
"rev": "f61125a668a320878494449750330ca58b78c557",
"type": "github"
},
"original": {
@@ -36,10 +36,32 @@
"type": "github"
}
},
"quickshell": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1764663772,
"narHash": "sha256-sHqLmm0wAt3PC4vczJeBozI1/f4rv9yp3IjkClHDXDs=",
"ref": "refs/heads/master",
"rev": "26531fc46ef17e9365b03770edd3fb9206fcb460",
"revCount": 713,
"type": "git",
"url": "https://git.outfoxxed.me/quickshell/quickshell"
},
"original": {
"rev": "26531fc46ef17e9365b03770edd3fb9206fcb460",
"type": "git",
"url": "https://git.outfoxxed.me/quickshell/quickshell"
}
},
"root": {
"inputs": {
"dgop": "dgop",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"quickshell": "quickshell"
}
}
},
+93 -45
View File
@@ -7,12 +7,17 @@
url = "github:AvengeMedia/dgop";
inputs.nixpkgs.follows = "nixpkgs";
};
quickshell = {
url = "git+https://git.outfoxxed.me/quickshell/quickshell?rev=26531fc46ef17e9365b03770edd3fb9206fcb460";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
dgop,
quickshell,
...
}: let
forEachSystem = fn:
@@ -20,8 +25,9 @@
system: fn system nixpkgs.legacyPackages.${system}
);
buildDmsPkgs = pkgs: {
inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) dmsCli dankMaterialShell;
dms-shell = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
dgop = dgop.packages.${pkgs.stdenv.hostPlatform.system}.dgop;
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
mkModuleWithDmsPkgs = path: args @ {pkgs, ...}: {
imports = [
@@ -46,62 +52,71 @@
+ "_"
+ (self.shortRev or "dirty");
in {
dmsCli = pkgs.buildGoModule (finalAttrs: {
inherit version;
dms-shell = pkgs.buildGoModule (
let
rootSrc = ./.;
in {
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-2PCqiW4frxME8IlmwWH5ktznhd/G1bah5Ae4dp0HPTQ=";
pname = "dmsCli";
src = ./core;
vendorHash = "sha256-2PCqiW4frxME8IlmwWH5ktznhd/G1bah5Ae4dp0HPTQ=";
subPackages = ["cmd/dms"];
subPackages = ["cmd/dms"];
ldflags = [
"-s"
"-w"
"-X main.Version=${version}"
];
ldflags = [
"-s"
"-w"
"-X main.Version=${finalAttrs.version}"
];
nativeBuildInputs = with pkgs; [
installShellFiles
makeWrapper
];
nativeBuildInputs = [pkgs.installShellFiles];
postInstall = ''
mkdir -p $out/share/quickshell/dms
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
postInstall = ''
installShellCompletion --cmd dms \
--bash <($out/bin/dms completion bash) \
--fish <($out/bin/dms completion fish ) \
--zsh <($out/bin/dms completion zsh)
'';
chmod u+w $out/share/quickshell/dms/VERSION
echo "${version}" > $out/share/quickshell/dms/VERSION
meta = {
description = "DankMaterialShell Command Line Interface";
homepage = "https://github.com/AvengeMedia/danklinux";
mainProgram = "dms";
license = pkgs.lib.licenses.mit;
platforms = pkgs.lib.platforms.unix;
};
});
# Install desktop file and icon
install -D ${rootSrc}/assets/dms-open.desktop \
$out/share/applications/dms-open.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg
dankMaterialShell = pkgs.stdenvNoCC.mkDerivation {
inherit version;
wrapProgram $out/bin/dms --add-flags "-c $out/share/quickshell/dms"
pname = "dankMaterialShell";
src = ./quickshell;
installPhase = ''
mkdir -p $out/etc/xdg/quickshell
cp -r ./ $out/etc/xdg/quickshell/dms
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
$out/lib/systemd/user/dms.service
# Create DMS Version file
echo "${version}" > $out/etc/xdg/quickshell/dms/VERSION
substituteInPlace $out/lib/systemd/user/dms.service \
--replace-fail /usr/bin/dms $out/bin/dms \
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
# Install desktop file
mkdir -p $out/share/applications
cp ${./assets/dms-open.desktop} $out/share/applications/dms-open.desktop
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
# Install icon
mkdir -p $out/share/icons/hicolor/scalable/apps
cp ${./core/assets/danklogo.svg} $out/share/icons/hicolor/scalable/apps/danklogo.svg
'';
};
installShellCompletion --cmd dms \
--bash <($out/bin/dms completion bash) \
--fish <($out/bin/dms completion fish) \
--zsh <($out/bin/dms completion zsh)
'';
default = self.packages.${system}.dmsCli;
meta = {
description = "Desktop shell for wayland compositors built with Quickshell & GO";
homepage = "https://danklinux.com";
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
license = pkgs.lib.licenses.mit;
mainProgram = "dms";
platforms = pkgs.lib.platforms.linux;
};
}
);
default = self.packages.${system}.dms-shell;
}
);
@@ -112,5 +127,38 @@
nixosModules.dankMaterialShell = mkModuleWithDmsPkgs ./distro/nix/nixos.nix;
nixosModules.greeter = mkModuleWithDmsPkgs ./distro/nix/greeter.nix;
devShells = forEachSystem (
system: pkgs: let
qmlPkgs =
[
quickshell.packages.${system}.default
]
++ (with pkgs.kdePackages; [
qtdeclarative
kirigami.unwrapped
sonnet
qtmultimedia
]);
in {
default = pkgs.mkShell {
buildInputs = with pkgs;
[
go_1_24
gopls
delve
go-tools
gnumake
]
++ qmlPkgs;
shellHook = ''
touch quickshell/.qmlls.ini 2>/dev/null
'';
QML2_IMPORT_PATH = pkgs.lib.concatStringsSep ":" (map (o: "${o}/lib/qt-6/qml") qmlPkgs);
};
}
);
};
}
+58 -29
View File
@@ -197,14 +197,26 @@ const ACTION_ARGS = {
{ name: "focus", type: "bool", label: "Follow focus", default: false }
]
},
"move-column-to-workspace-down": {
args: [{ name: "focus", type: "bool", label: "Follow focus", default: false }]
},
"move-column-to-workspace-up": {
args: [{ name: "focus", type: "bool", label: "Follow focus", default: false }]
},
"screenshot": {
args: [{ name: "opts", type: "screenshot", label: "Options" }]
args: [{ name: "show-pointer", type: "bool", label: "Show pointer" }]
},
"screenshot-screen": {
args: [{ name: "opts", type: "screenshot", label: "Options" }]
args: [
{ name: "show-pointer", type: "bool", label: "Show pointer" },
{ name: "write-to-disk", type: "bool", label: "Save to disk" }
]
},
"screenshot-window": {
args: [{ name: "opts", type: "screenshot", label: "Options" }]
args: [
{ name: "show-pointer", type: "bool", label: "Show pointer" },
{ name: "write-to-disk", type: "bool", label: "Save to disk" }
]
}
};
@@ -288,11 +300,12 @@ function getActionLabel(action) {
if (!action)
return "";
const dmsAct = findDmsAction(action);
var dmsAct = findDmsAction(action);
if (dmsAct)
return dmsAct.label;
const compAct = findCompositorAction(action);
var base = action.split(" ")[0];
var compAct = findCompositorAction(base);
if (compAct)
return compAct.label;
@@ -337,7 +350,8 @@ function isValidAction(action) {
function isKnownCompositorAction(action) {
if (!action)
return false;
return findCompositorAction(action) !== null;
var base = action.split(" ")[0];
return findCompositorAction(base) !== null;
}
function buildSpawnAction(command, args) {
@@ -404,10 +418,10 @@ function parseCompositorActionArgs(action) {
if (!ACTION_ARGS[base])
return { base: action, args: {} };
var argConfig = ACTION_ARGS[base];
var argParts = parts.slice(1);
if (base === "move-column-to-workspace") {
switch (base) {
case "move-column-to-workspace":
for (var i = 0; i < argParts.length; i++) {
if (argParts[i] === "focus=true" || argParts[i] === "focus=false") {
args.focus = argParts[i] === "focus=true";
@@ -415,14 +429,24 @@ function parseCompositorActionArgs(action) {
args.index = argParts[i];
}
}
} else if (base.startsWith("screenshot")) {
args.opts = {};
for (var j = 0; j < argParts.length; j += 2) {
if (j + 1 < argParts.length)
args.opts[argParts[j]] = argParts[j + 1];
break;
case "move-column-to-workspace-down":
case "move-column-to-workspace-up":
for (var k = 0; k < argParts.length; k++) {
if (argParts[k] === "focus=true" || argParts[k] === "focus=false")
args.focus = argParts[k] === "focus=true";
}
break;
default:
if (base.startsWith("screenshot")) {
for (var j = 0; j < argParts.length; j++) {
var kv = argParts[j].split("=");
if (kv.length === 2)
args[kv[0]] = kv[1] === "true";
}
} else if (argParts.length > 0) {
args.value = argParts.join(" ");
}
} else if (argParts.length > 0) {
args.value = argParts.join(" ");
}
return { base: base, args: args };
@@ -437,24 +461,29 @@ function buildCompositorAction(base, args) {
if (!args || Object.keys(args).length === 0)
return base;
if (base === "move-column-to-workspace") {
switch (base) {
case "move-column-to-workspace":
if (args.index)
parts.push(args.index);
if (args.focus === true)
parts.push("focus=true");
else if (args.focus === false)
if (args.focus === false)
parts.push("focus=false");
} else if (base.startsWith("screenshot") && args.opts) {
for (var key in args.opts) {
if (args.opts[key] !== undefined && args.opts[key] !== "") {
parts.push(key);
parts.push(args.opts[key]);
}
break;
case "move-column-to-workspace-down":
case "move-column-to-workspace-up":
if (args.focus === false)
parts.push("focus=false");
break;
default:
if (base.startsWith("screenshot")) {
if (args["show-pointer"] === true)
parts.push("show-pointer=true");
if (args["write-to-disk"] === true)
parts.push("write-to-disk=true");
} else if (args.value) {
parts.push(args.value);
} else if (args.index) {
parts.push(args.index);
}
} else if (args.value) {
parts.push(args.value);
} else if (args.index) {
parts.push(args.index);
}
return parts.join(" ");
+9 -1
View File
@@ -16,7 +16,15 @@ Singleton {
const currentOSD = currentOSDsByScreen[screenName];
if (currentOSD && currentOSD !== osd) {
currentOSD.hide();
if (typeof currentOSD.hide === "function") {
try {
currentOSD.hide();
} catch (e) {
currentOSDsByScreen[screenName] = null;
}
} else {
currentOSDsByScreen[screenName] = null;
}
}
currentOSDsByScreen[screenName] = osd;
+3 -3
View File
@@ -105,7 +105,7 @@ Singleton {
property bool controlCenterShowNetworkIcon: true
property bool controlCenterShowBluetoothIcon: true
property bool controlCenterShowAudioIcon: true
property bool controlCenterShowVpnIcon: false
property bool controlCenterShowVpnIcon: true
property bool controlCenterShowBrightnessIcon: false
property bool controlCenterShowMicIcon: false
property bool controlCenterShowBatteryIcon: false
@@ -295,7 +295,7 @@ Singleton {
property bool lockScreenShowPowerActions: true
property bool enableFprint: false
property int maxFprintTries: 3
property int maxFprintTries: 15
property bool fprintdAvailable: false
property string lockScreenActiveMonitor: "all"
property string lockScreenInactiveColor: "#000000"
@@ -318,7 +318,7 @@ Singleton {
property bool osdAudioOutputEnabled: true
property bool powerActionConfirm: true
property int powerActionHoldDuration: 1
property real powerActionHoldDuration: 0.5
property var powerMenuActions: ["reboot", "logout", "poweroff", "lock", "suspend", "restart"]
property string powerMenuDefaultAction: "logout"
property bool powerMenuGridLayout: false
+27 -10
View File
@@ -820,18 +820,35 @@ Singleton {
"runUserTemplates": (typeof SettingsData !== "undefined") ? SettingsData.runUserMatugenTemplates : true
};
if (stockColors) {
desired.stockColors = JSON.stringify(stockColors);
}
const json = JSON.stringify(desired);
const desiredPath = stateDir + "/matugen.desired.json";
const syncModeWithPortal = (typeof SettingsData !== "undefined" && SettingsData.syncModeWithPortal) ? "true" : "false";
const terminalsAlwaysDark = (typeof SettingsData !== "undefined" && SettingsData.terminalsAlwaysDark) ? "true" : "false";
console.log("Theme: Starting matugen worker");
workerRunning = true;
systemThemeGenerator.command = ["sh", "-c", `mkdir -p '${stateDir}' && cat > '${desiredPath}' << 'EOF'\n${json}\nEOF\nexec '${shellDir}/scripts/matugen-worker.sh' '${stateDir}' '${shellDir}' '${configDir}' '${syncModeWithPortal}' '${terminalsAlwaysDark}' --run`];
const args = [
"dms", "matugen", "queue",
"--state-dir", stateDir,
"--shell-dir", shellDir,
"--config-dir", configDir,
"--kind", desired.kind,
"--value", desired.value,
"--mode", desired.mode,
"--icon-theme", desired.iconTheme,
"--matugen-type", desired.matugenType,
];
if (!desired.runUserTemplates) {
args.push("--run-user-templates=false");
}
if (stockColors) {
args.push("--stock-colors", JSON.stringify(stockColors));
}
if (typeof SettingsData !== "undefined" && SettingsData.syncModeWithPortal) {
args.push("--sync-mode-with-portal");
}
if (typeof SettingsData !== "undefined" && SettingsData.terminalsAlwaysDark) {
args.push("--terminals-always-dark");
}
systemThemeGenerator.command = args;
systemThemeGenerator.running = true;
}
+3 -3
View File
@@ -51,7 +51,7 @@ var SPEC = {
controlCenterShowNetworkIcon: { def: true },
controlCenterShowBluetoothIcon: { def: true },
controlCenterShowAudioIcon: { def: true },
controlCenterShowVpnIcon: { def: false },
controlCenterShowVpnIcon: { def: true },
controlCenterShowBrightnessIcon: { def: false },
controlCenterShowMicIcon: { def: false },
controlCenterShowBatteryIcon: { def: false },
@@ -194,7 +194,7 @@ var SPEC = {
lockScreenShowPowerActions: { def: true },
enableFprint: { def: false },
maxFprintTries: { def: 3 },
maxFprintTries: { def: 15 },
fprintdAvailable: { def: false, persist: false },
lockScreenActiveMonitor: { def: "all" },
lockScreenInactiveColor: { def: "#000000" },
@@ -217,7 +217,7 @@ var SPEC = {
osdAudioOutputEnabled: { def: true },
powerActionConfirm: { def: true },
powerActionHoldDuration: { def: 1 },
powerActionHoldDuration: { def: 0.5 },
powerMenuActions: { def: ["reboot", "logout", "poweroff", "lock", "suspend", "restart"] },
powerMenuDefaultAction: { def: "logout" },
powerMenuGridLayout: { def: false },
+9 -3
View File
@@ -787,12 +787,18 @@ DankModal {
}
StyledText {
readonly property int remainingSeconds: Math.ceil(SettingsData.powerActionHoldDuration * (1 - root.holdProgress))
readonly property real totalMs: SettingsData.powerActionHoldDuration * 1000
readonly property int remainingMs: Math.ceil(totalMs * (1 - root.holdProgress))
text: {
if (root.showHoldHint)
return I18n.tr("Hold longer to confirm");
if (root.holdProgress > 0)
return I18n.tr("Hold to confirm (%1s)").arg(remainingSeconds);
if (root.holdProgress > 0) {
if (totalMs < 1000)
return I18n.tr("Hold to confirm (%1 ms)").arg(remainingMs);
return I18n.tr("Hold to confirm (%1s)").arg(Math.ceil(remainingMs / 1000));
}
if (totalMs < 1000)
return I18n.tr("Hold to confirm (%1 ms)").arg(totalMs);
return I18n.tr("Hold to confirm (%1s)").arg(SettingsData.powerActionHoldDuration);
}
font.pixelSize: Theme.fontSizeSmall
@@ -56,6 +56,8 @@ Row {
}
DankSlider {
id: volumeSlider
readonly property real actualVolumePercent: defaultSink ? Math.round(defaultSink.audio.volume * 100) : 0
anchors.verticalCenter: parent.verticalCenter
@@ -63,7 +65,6 @@ Row {
enabled: defaultSink !== null
minimum: 0
maximum: 100
value: defaultSink ? Math.min(100, Math.round(defaultSink.audio.volume * 100)) : 0
showValue: true
unit: "%"
valueOverride: actualVolumePercent
@@ -81,4 +82,11 @@ Row {
}
}
}
Binding {
target: volumeSlider
property: "value"
value: defaultSink ? Math.min(100, Math.round(defaultSink.audio.volume * 100)) : 0
when: !volumeSlider.isDragging
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ Item {
id: barBorderShape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
visible: (barConfig?.borderEnabled ?? false) && !barWindow.hasMaximizedToplevel
visible: barConfig?.borderEnabled ?? false
ShapePath {
fillColor: "transparent"
+1 -1
View File
@@ -330,7 +330,7 @@ Item {
property var itemData: modelData
readonly property real itemSpacing: root.widgetSpacing
width: widgetLoader.item ? widgetLoader.item.width : 0
width: root.isVertical ? root.width : (widgetLoader.item ? widgetLoader.item.width : 0)
height: widgetLoader.item ? widgetLoader.item.height : 0
readonly property bool active: widgetLoader.active
+54 -32
View File
@@ -302,7 +302,8 @@ Item {
"vpn": vpnComponent,
"notepadButton": notepadButtonComponent,
"colorPicker": colorPickerComponent,
"systemUpdate": systemUpdateComponent
"systemUpdate": systemUpdateComponent,
"powerMenuButton": powerMenuButtonComponent
};
let pluginMap = PluginService.getWidgetComponents();
@@ -314,36 +315,37 @@ Item {
}
readonly property var allComponents: ({
"launcherButtonComponent": launcherButtonComponent,
"workspaceSwitcherComponent": workspaceSwitcherComponent,
"focusedWindowComponent": focusedWindowComponent,
"runningAppsComponent": runningAppsComponent,
"clockComponent": clockComponent,
"mediaComponent": mediaComponent,
"weatherComponent": weatherComponent,
"systemTrayComponent": systemTrayComponent,
"privacyIndicatorComponent": privacyIndicatorComponent,
"clipboardComponent": clipboardComponent,
"cpuUsageComponent": cpuUsageComponent,
"memUsageComponent": memUsageComponent,
"diskUsageComponent": diskUsageComponent,
"cpuTempComponent": cpuTempComponent,
"gpuTempComponent": gpuTempComponent,
"notificationButtonComponent": notificationButtonComponent,
"batteryComponent": batteryComponent,
"layoutComponent": layoutComponent,
"controlCenterButtonComponent": controlCenterButtonComponent,
"capsLockIndicatorComponent": capsLockIndicatorComponent,
"idleInhibitorComponent": idleInhibitorComponent,
"spacerComponent": spacerComponent,
"separatorComponent": separatorComponent,
"networkComponent": networkComponent,
"keyboardLayoutNameComponent": keyboardLayoutNameComponent,
"vpnComponent": vpnComponent,
"notepadButtonComponent": notepadButtonComponent,
"colorPickerComponent": colorPickerComponent,
"systemUpdateComponent": systemUpdateComponent
})
"launcherButtonComponent": launcherButtonComponent,
"workspaceSwitcherComponent": workspaceSwitcherComponent,
"focusedWindowComponent": focusedWindowComponent,
"runningAppsComponent": runningAppsComponent,
"clockComponent": clockComponent,
"mediaComponent": mediaComponent,
"weatherComponent": weatherComponent,
"systemTrayComponent": systemTrayComponent,
"privacyIndicatorComponent": privacyIndicatorComponent,
"clipboardComponent": clipboardComponent,
"cpuUsageComponent": cpuUsageComponent,
"memUsageComponent": memUsageComponent,
"diskUsageComponent": diskUsageComponent,
"cpuTempComponent": cpuTempComponent,
"gpuTempComponent": gpuTempComponent,
"notificationButtonComponent": notificationButtonComponent,
"batteryComponent": batteryComponent,
"layoutComponent": layoutComponent,
"controlCenterButtonComponent": controlCenterButtonComponent,
"capsLockIndicatorComponent": capsLockIndicatorComponent,
"idleInhibitorComponent": idleInhibitorComponent,
"spacerComponent": spacerComponent,
"separatorComponent": separatorComponent,
"networkComponent": networkComponent,
"keyboardLayoutNameComponent": keyboardLayoutNameComponent,
"vpnComponent": vpnComponent,
"notepadButtonComponent": notepadButtonComponent,
"colorPickerComponent": colorPickerComponent,
"systemUpdateComponent": systemUpdateComponent,
"powerMenuButtonComponent": powerMenuButtonComponent
})
Item {
id: stackContainer
@@ -532,7 +534,27 @@ Item {
section: topBarContent.getWidgetSection(parent)
parentScreen: barWindow.screen
onClicked: {
clipboardHistoryModalPopup.toggle();
clipboardHistoryModalPopup.toggle()
}
}
}
Component {
id: powerMenuButtonComponent
PowerMenuButton {
widgetThickness: barWindow.widgetThickness
barThickness: barWindow.effectiveBarThickness
axis: barWindow.axis
section: topBarContent.getWidgetSection(parent)
parentScreen: barWindow.screen
onClicked: {
if (powerMenuModalLoader) {
powerMenuModalLoader.active = true
if (powerMenuModalLoader.item) {
powerMenuModalLoader.item.openCentered()
}
}
}
}
}
+9 -4
View File
@@ -23,6 +23,7 @@ Loader {
property bool isRightBarEdge: false
property bool isTopBarEdge: false
property bool isBottomBarEdge: false
property string _registeredScreenName: ""
asynchronous: false
@@ -198,13 +199,16 @@ Loader {
if (!hasPopout)
return;
BarWidgetService.registerWidget(widgetId, parentScreen.name, item);
_registeredScreenName = parentScreen.name;
BarWidgetService.registerWidget(widgetId, _registeredScreenName, item);
}
function unregisterWidget() {
if (!widgetId || !parentScreen?.name)
if (!widgetId || !_registeredScreenName)
return;
BarWidgetService.unregisterWidget(widgetId, parentScreen.name);
BarWidgetService.unregisterWidget(widgetId, _registeredScreenName);
_registeredScreenName = "";
}
function getWidgetComponent(widgetId, components) {
@@ -237,7 +241,8 @@ Loader {
"notepadButton": components.notepadButtonComponent,
"colorPicker": components.colorPickerComponent,
"systemUpdate": components.systemUpdateComponent,
"layout": components.layoutComponent
"layout": components.layoutComponent,
"powerMenuButton": components.powerMenuButtonComponent
};
if (componentMap[widgetId]) {
@@ -17,7 +17,7 @@ BasePill {
DankIcon {
anchors.centerIn: parent
name: "content_paste"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: Theme.widgetIconColor
}
}
@@ -173,7 +173,7 @@ BasePill {
DankIcon {
name: root.getNetworkIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.getNetworkIconColor()
anchors.horizontalCenter: parent.horizontalCenter
visible: root.showNetworkIcon && NetworkService.networkAvailable
@@ -181,7 +181,7 @@ BasePill {
DankIcon {
name: "vpn_lock"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: NetworkService.vpnConnected ? Theme.primary : Theme.outlineButton
anchors.horizontalCenter: parent.horizontalCenter
visible: root.showVpnIcon && NetworkService.vpnAvailable && NetworkService.vpnConnected
@@ -189,7 +189,7 @@ BasePill {
DankIcon {
name: "bluetooth"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: BluetoothService.connected ? Theme.primary : Theme.outlineButton
anchors.horizontalCenter: parent.horizontalCenter
visible: root.showBluetoothIcon && BluetoothService.available && BluetoothService.enabled
@@ -205,7 +205,7 @@ BasePill {
DankIcon {
id: audioIconV
name: root.getVolumeIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: Theme.widgetIconColor
anchors.centerIn: parent
}
@@ -230,7 +230,7 @@ BasePill {
DankIcon {
id: micIconV
name: root.getMicIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.getMicIconColor()
anchors.centerIn: parent
}
@@ -255,7 +255,7 @@ BasePill {
DankIcon {
id: brightnessIconV
name: root.getBrightnessIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: Theme.widgetIconColor
anchors.centerIn: parent
}
@@ -272,7 +272,7 @@ BasePill {
DankIcon {
name: Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable)
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.getBatteryIconColor()
anchors.horizontalCenter: parent.horizontalCenter
visible: root.showBatteryIcon && BatteryService.batteryAvailable
@@ -280,7 +280,7 @@ BasePill {
DankIcon {
name: "print"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: Theme.primary
anchors.horizontalCenter: parent.horizontalCenter
visible: root.showPrinterIcon && CupsService.cupsAvailable && root.hasPrintJobs()
@@ -288,7 +288,7 @@ BasePill {
DankIcon {
name: "settings"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.isActive ? Theme.primary : Theme.widgetIconColor
anchors.horizontalCenter: parent.horizontalCenter
visible: root.hasNoVisibleIcons()
@@ -304,7 +304,7 @@ BasePill {
DankIcon {
id: networkIcon
name: root.getNetworkIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.getNetworkIconColor()
anchors.verticalCenter: parent.verticalCenter
visible: root.showNetworkIcon && NetworkService.networkAvailable
@@ -313,7 +313,7 @@ BasePill {
DankIcon {
id: vpnIcon
name: "vpn_lock"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: NetworkService.vpnConnected ? Theme.primary : Theme.outlineButton
anchors.verticalCenter: parent.verticalCenter
visible: root.showVpnIcon && NetworkService.vpnAvailable && NetworkService.vpnConnected
@@ -322,7 +322,7 @@ BasePill {
DankIcon {
id: bluetoothIcon
name: "bluetooth"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: BluetoothService.connected ? Theme.primary : Theme.outlineButton
anchors.verticalCenter: parent.verticalCenter
visible: root.showBluetoothIcon && BluetoothService.available && BluetoothService.enabled
@@ -338,7 +338,7 @@ BasePill {
DankIcon {
id: audioIcon
name: root.getVolumeIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: Theme.widgetIconColor
anchors.centerIn: parent
}
@@ -364,7 +364,7 @@ BasePill {
DankIcon {
id: micIcon
name: root.getMicIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.getMicIconColor()
anchors.centerIn: parent
}
@@ -390,7 +390,7 @@ BasePill {
DankIcon {
id: brightnessIcon
name: root.getBrightnessIconName()
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: Theme.widgetIconColor
anchors.centerIn: parent
}
@@ -409,7 +409,7 @@ BasePill {
DankIcon {
id: batteryIcon
name: Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable)
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.getBatteryIconColor()
anchors.verticalCenter: parent.verticalCenter
visible: root.showBatteryIcon && BatteryService.batteryAvailable
@@ -418,7 +418,7 @@ BasePill {
DankIcon {
id: printerIcon
name: "print"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
visible: root.showPrinterIcon && CupsService.cupsAvailable && root.hasPrintJobs()
@@ -426,7 +426,7 @@ BasePill {
DankIcon {
name: "settings"
size: Theme.barIconSize(root.barThickness)
size: Theme.barIconSize(root.barThickness, -4)
color: root.isActive ? Theme.primary : Theme.widgetIconColor
anchors.verticalCenter: parent.verticalCenter
visible: root.hasNoVisibleIcons()
@@ -0,0 +1,24 @@
import QtQuick
import qs.Common
import qs.Modules.Plugins
import qs.Widgets
BasePill {
id: root
property bool isActive: false
content: Component {
Item {
implicitWidth: root.widgetThickness - root.horizontalPadding * 2
implicitHeight: root.widgetThickness - root.horizontalPadding * 2
DankIcon {
anchors.centerIn: parent
name: "power_settings_new"
size: Theme.barIconSize(root.barThickness)
color: Theme.widgetIconColor
}
}
}
}
@@ -541,6 +541,34 @@ Item {
}
}
function getWorkspaceIndex(modelData) {
let isPlaceholder;
if (root.useExtWorkspace) {
isPlaceholder = modelData?.hidden === true;
} else if (CompositorService.isHyprland) {
isPlaceholder = modelData?.id === -1;
} else if (CompositorService.isDwl) {
isPlaceholder = modelData?.tag === -1;
} else if (CompositorService.isSway) {
isPlaceholder = modelData?.num === -1;
} else {
isPlaceholder = modelData === -1;
}
if (isPlaceholder)
return index + 1;
if (root.useExtWorkspace)
return index + 1;
if (CompositorService.isHyprland)
return modelData?.id || "";
if (CompositorService.isDwl)
return (modelData?.tag !== undefined) ? (modelData.tag + 1) : "";
if (CompositorService.isSway)
return modelData?.num || "";
return modelData - 1;
}
readonly property bool hasNativeWorkspaceSupport: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway
readonly property bool hasWorkspaces: getRealWorkspaces().length > 0
readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces)
@@ -862,7 +890,18 @@ Item {
id: rowLayout
Row {
spacing: 4
visible: loadedIcons.length > 0
visible: loadedIcons.length > 0 || SettingsData.showWorkspaceIndex
StyledText {
topPadding: 2
rightPadding: isActive ? 4 : 0
visible: SettingsData.showWorkspaceIndex
text: {
return root.getWorkspaceIndex(modelData);
}
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
font.pixelSize: Theme.barTextSize(barThickness, barConfig?.fontScale)
font.weight: (isActive && !isPlaceholder) ? Font.DemiBold : Font.Normal
}
Repeater {
model: ScriptModel {
@@ -1045,31 +1084,7 @@ Item {
StyledText {
anchors.centerIn: parent
text: {
let isPlaceholder;
if (root.useExtWorkspace) {
isPlaceholder = modelData?.hidden === true;
} else if (CompositorService.isHyprland) {
isPlaceholder = modelData?.id === -1;
} else if (CompositorService.isDwl) {
isPlaceholder = modelData?.tag === -1;
} else if (CompositorService.isSway) {
isPlaceholder = modelData?.num === -1;
} else {
isPlaceholder = modelData === -1;
}
if (isPlaceholder)
return index + 1;
if (root.useExtWorkspace)
return index + 1;
if (CompositorService.isHyprland)
return modelData?.id || "";
if (CompositorService.isDwl)
return (modelData?.tag !== undefined) ? (modelData.tag + 1) : "";
if (CompositorService.isSway)
return modelData?.num || "";
return modelData - 1;
return root.getWorkspaceIndex(modelData);
}
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
font.pixelSize: Theme.barTextSize(barThickness, barConfig?.fontScale)
+66 -129
View File
@@ -51,17 +51,9 @@ Item {
readonly property bool usePlayerVolume: activePlayer && activePlayer.volumeSupported && !__isChromeBrowser
readonly property real currentVolume: usePlayerVolume ? activePlayer.volume : (AudioService.sink?.audio?.volume ?? 0)
// Palette that stays stable across track switches until new colors are ready
property color dom: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 1.0)
property color acc: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.25)
property color _nextDom: dom
property color _nextAcc: acc
// Track-switch hold (prevents banner flicker only during switches)
property bool isSwitching: false
property bool paletteReady: false
property string _lastArtUrl: ""
property url _cqSource: ""
property string _bgArtSource: ""
// Derived "no players" state: always correct, no timers.
readonly property int _playerCount: allPlayers ? allPlayers.length : 0
@@ -69,7 +61,6 @@ Item {
readonly property bool _trulyIdle: activePlayer && activePlayer.playbackState === MprisPlaybackState.Stopped && !activePlayer.trackTitle && !activePlayer.trackArtist
readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || _trulyIdle)
// Short hold only during track switches (not when players disappear)
property bool _switchHold: false
Timer {
id: _switchHoldTimer
@@ -86,11 +77,9 @@ Item {
}
isSwitching = true;
_switchHold = true;
paletteReady = false;
_switchHoldTimer.restart();
if (activePlayer.trackArtUrl) {
if (activePlayer.trackArtUrl)
loadArtwork(activePlayer.trackArtUrl);
}
}
property string activeTrackArtFile: ""
@@ -108,13 +97,13 @@ Item {
imageDownloader.command = ["curl", "-L", "-s", "--user-agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", "-o", filename, url];
imageDownloader.targetFile = filename;
imageDownloader.running = true;
} else {
_preloadImage.source = url;
return;
}
_bgArtSource = url;
}
function maybeFinishSwitch() {
if (activePlayer && activePlayer.trackTitle !== "" && paletteReady) {
if (activePlayer && activePlayer.trackTitle !== "") {
isSwitching = false;
_switchHold = false;
}
@@ -219,9 +208,8 @@ Item {
property string targetFile: ""
onExited: exitCode => {
if (exitCode === 0 && targetFile) {
_preloadImage.source = "file://" + targetFile;
}
if (exitCode === 0 && targetFile)
_bgArtSource = "file://" + targetFile;
}
}
@@ -230,121 +218,70 @@ Item {
running: false
}
Image {
id: _preloadImage
source: ""
asynchronous: true
cache: true
visible: false
onStatusChanged: {
if (status === Image.Ready) {
_cqSource = source;
colorQuantizer.source = _cqSource;
} else if (status === Image.Error) {
_cqSource = "";
}
}
}
ColorQuantizer {
id: colorQuantizer
source: _cqSource !== "" ? _cqSource : undefined
depth: 8
rescaleSize: 32
onColorsChanged: {
if (!colors || colors.length === 0)
return;
function enhanceColor(color) {
const satBoost = 1.4;
const valueBoost = 1.2;
return Qt.hsva(color.hsvHue, Math.min(1, color.hsvSaturation * satBoost), Math.min(1, color.hsvValue * valueBoost), color.a);
}
function getExtremeColor(startIdx, direction = 1) {
let bestColor = colors[startIdx];
let bestScore = 0;
for (let i = startIdx; i >= 0 && i < colors.length; i += direction) {
const c = colors[i];
const saturation = c.hsvSaturation;
const brightness = c.hsvValue;
const contrast = Math.abs(brightness - 0.5) * 2;
const score = saturation * 0.7 + contrast * 0.3;
if (score > bestScore) {
bestScore = score;
bestColor = c;
}
}
return enhanceColor(bestColor);
}
_pendingDom = getExtremeColor(Math.floor(colors.length * 0.2), 1);
_pendingAcc = getExtremeColor(Math.floor(colors.length * 0.8), -1);
paletteApplyDelay.restart();
}
}
property color _pendingDom: dom
property color _pendingAcc: acc
Timer {
id: paletteApplyDelay
interval: 90
repeat: false
onTriggered: {
const dist = (c1, c2) => {
const dr = c1.r - c2.r, dg = c1.g - c2.g, db = c1.b - c2.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
};
const domChanged = dist(_pendingDom, dom) > 0.02;
const accChanged = dist(_pendingAcc, acc) > 0.02;
if (domChanged || accChanged) {
dom = _pendingDom;
acc = _pendingAcc;
}
paletteReady = true;
maybeFinishSwitch();
}
}
property bool isSeeking: false
Rectangle {
Item {
id: bgContainer
anchors.fill: parent
radius: Theme.cornerRadius
opacity: 1.0
gradient: Gradient {
GradientStop {
position: 0.0
color: Qt.rgba(dom.r, dom.g, dom.b, paletteReady ? 0.38 : 0.06)
}
GradientStop {
position: 0.3
color: Qt.rgba(acc.r, acc.g, acc.b, paletteReady ? 0.28 : 0.05)
}
GradientStop {
position: 1.0
color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, paletteReady ? 0.92 : 0.985)
}
}
Behavior on opacity {
NumberAnimation {
duration: 160
}
}
}
visible: _bgArtSource !== ""
Behavior on dom {
ColorAnimation {
duration: 220
easing.type: Easing.InOutQuad
Image {
id: bgImage
anchors.centerIn: parent
width: Math.max(parent.width, parent.height) * 1.1
height: width
source: _bgArtSource
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
visible: false
onStatusChanged: {
if (status === Image.Ready)
maybeFinishSwitch();
}
}
}
Behavior on acc {
ColorAnimation {
duration: 220
easing.type: Easing.InOutQuad
Item {
id: blurredBg
anchors.fill: parent
visible: false
MultiEffect {
anchors.centerIn: parent
width: bgImage.width
height: bgImage.height
source: bgImage
blurEnabled: true
blurMax: 64
blur: 0.8
saturation: -0.2
brightness: -0.25
}
}
Rectangle {
id: bgMask
anchors.fill: parent
radius: Theme.cornerRadius
visible: false
layer.enabled: true
}
MultiEffect {
anchors.fill: parent
source: blurredBg
maskEnabled: true
maskSource: bgMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
opacity: 0.7
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surface
opacity: 0.3
}
}
+9 -3
View File
@@ -780,12 +780,18 @@ Rectangle {
}
StyledText {
readonly property int remainingSeconds: Math.ceil(SettingsData.powerActionHoldDuration * (1 - root.holdProgress))
readonly property real totalMs: SettingsData.powerActionHoldDuration * 1000
readonly property int remainingMs: Math.ceil(totalMs * (1 - root.holdProgress))
text: {
if (root.showHoldHint)
return I18n.tr("Hold longer to confirm");
if (root.holdProgress > 0)
return I18n.tr("Hold to confirm (%1s)").arg(remainingSeconds);
if (root.holdProgress > 0) {
if (totalMs < 1000)
return I18n.tr("Hold to confirm (%1 ms)").arg(remainingMs);
return I18n.tr("Hold to confirm (%1s)").arg(Math.ceil(remainingMs / 1000));
}
if (totalMs < 1000)
return I18n.tr("Hold to confirm (%1 ms)").arg(totalMs);
return I18n.tr("Hold to confirm (%1s)").arg(SettingsData.powerActionHoldDuration);
}
font.pixelSize: Theme.fontSizeSmall
+22 -3
View File
@@ -1086,14 +1086,33 @@ Item {
Row {
spacing: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
visible: NetworkService.networkStatus !== "disconnected" || (BluetoothService.available && BluetoothService.enabled) || (AudioService.sink && AudioService.sink.audio)
visible: NetworkService.networkAvailable || (BluetoothService.available && BluetoothService.enabled) || (AudioService.sink && AudioService.sink.audio)
DankIcon {
name: NetworkService.networkStatus === "ethernet" ? "lan" : NetworkService.wifiSignalIcon
name: {
if (NetworkService.wifiToggling)
return "sync";
switch (NetworkService.networkStatus) {
case "ethernet":
return "lan";
case "vpn":
return NetworkService.ethernetConnected ? "lan" : NetworkService.wifiSignalIcon;
default:
return NetworkService.wifiSignalIcon;
}
}
size: Theme.iconSize - 2
color: NetworkService.networkStatus !== "disconnected" ? "white" : Qt.rgba(255, 255, 255, 0.5)
anchors.verticalCenter: parent.verticalCenter
visible: NetworkService.networkStatus !== "disconnected"
visible: NetworkService.networkAvailable
}
DankIcon {
name: "vpn_lock"
size: Theme.iconSize - 2
color: NetworkService.vpnConnected ? Theme.primary : Qt.rgba(255, 255, 255, 0.5)
anchors.verticalCenter: parent.verticalCenter
visible: NetworkService.vpnAvailable && NetworkService.vpnConnected
}
DankIcon {
+4 -9
View File
@@ -59,12 +59,6 @@ Item {
"description": I18n.tr("Quick note-taking slideout panel"),
"icon": "sticky_note_2"
},
{
"id": "systemTray",
"name": I18n.tr("System Tray"),
"description": I18n.tr("System tray icons"),
"icon": "notifications"
}
];
}
@@ -199,8 +193,8 @@ Item {
DankDropdown {
width: parent.width - parent.leftPadding - parent.rightPadding
text: I18n.tr("Night Temperature")
description: I18n.tr("Color temperature for night mode")
text: SessionData.nightModeAutoEnabled ? I18n.tr("Night Temperature") : I18n.tr("Color Temperature")
description: SessionData.nightModeAutoEnabled ? I18n.tr("Color temperature for night mode") : I18n.tr("Warm color temperature to apply")
currentValue: SessionData.nightModeTemperature + "K"
options: {
var temps = [];
@@ -223,6 +217,7 @@ Item {
text: I18n.tr("Day Temperature")
description: I18n.tr("Color temperature for day time")
currentValue: SessionData.nightModeHighTemperature + "K"
visible: SessionData.nightModeAutoEnabled
options: {
var temps = [];
var minTemp = SessionData.nightModeTemperature;
@@ -821,7 +816,7 @@ Item {
const prefs = displaysTab.getScreenPreferences(parent.componentId);
const isAll = prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all");
const cid = parent.componentId;
const isRelevantComponent = ["dankBar", "dock", "notifications", "osd", "toast", "notepad", "systemTray"].includes(cid) || cid.startsWith("bar:");
const isRelevantComponent = ["dankBar", "dock", "notifications", "osd", "toast", "notepad"].includes(cid) || cid.startsWith("bar:");
return !isAll && isRelevantComponent;
}
onToggled: checked => {
+1 -1
View File
@@ -597,7 +597,7 @@ Item {
onSaveBind: (originalKey, newData) => {
KeybindsService.saveBind(originalKey, newData);
keybindsTab._editingKey = newData.key;
keybindsTab.expandedKey = modelData.action;
keybindsTab.expandedKey = newData.action;
}
onRemoveBind: key => {
const remainingKey = bindItem.keys.find(k => k.key !== key)?.key ?? "";
+19 -7
View File
@@ -408,15 +408,27 @@ Item {
onToggled: checked => SettingsData.set("powerActionConfirm", checked)
}
SettingsSliderRow {
SettingsDropdownRow {
id: holdDurationDropdown
property var durationOptions: ["250 ms", "500 ms", "750 ms", "1 second", "2 seconds", "3 seconds", "5 seconds", "10 seconds"]
property var durationValues: [0.25, 0.5, 0.75, 1, 2, 3, 5, 10]
text: I18n.tr("Hold Duration")
description: I18n.tr("How long to hold the button to confirm the action")
minimum: 1
maximum: 10
unit: "s"
options: durationOptions
visible: SettingsData.powerActionConfirm
value: SettingsData.powerActionHoldDuration
onSliderValueChanged: newValue => SettingsData.set("powerActionHoldDuration", newValue)
Component.onCompleted: {
const currentDuration = SettingsData.powerActionHoldDuration;
const index = durationValues.indexOf(currentDuration);
currentValue = index >= 0 ? durationOptions[index] : "500 ms";
}
onValueChanged: value => {
const index = durationOptions.indexOf(value);
if (index < 0)
return;
SettingsData.set("powerActionHoldDuration", durationValues[index]);
}
}
}
+8 -1
View File
@@ -239,7 +239,14 @@ Item {
"description": I18n.tr("Check for system updates"),
"icon": "update",
"enabled": SystemUpdateService.distributionSupported
}
},
{
"id": "powerMenuButton",
"text": I18n.tr("Power"),
"description": I18n.tr("Display the power system menu"),
"icon": "power_settings_new",
"enabled": true
},
];
var allPluginVariants = PluginService.getAllPluginVariants();
+15 -2
View File
@@ -506,7 +506,7 @@ Singleton {
DMSService.sendRequest("wayland.gamma.setTemperature", {
"low": temperature,
"high": 6500
"high": temperature
}, response => {
if (response.error) {
console.error("DisplayService: Failed to set temperature:", response.error);
@@ -752,15 +752,28 @@ Singleton {
Timer {
id: screenChangeRescanTimer
property int rescanAttempt: 0
interval: 3000
repeat: false
onTriggered: rescanDevices()
onTriggered: {
rescanDevices();
rescanAttempt++;
if (rescanAttempt < 3) {
interval = rescanAttempt === 1 ? 5000 : 8000;
restart();
} else {
rescanAttempt = 0;
interval = 3000;
}
}
}
Connections {
target: Quickshell
function onScreensChanged() {
screenChangeRescanTimer.rescanAttempt = 0;
screenChangeRescanTimer.interval = 3000;
screenChangeRescanTimer.restart();
}
}
+11 -1
View File
@@ -68,6 +68,8 @@ Item {
signal popoutClosed
signal backgroundClicked
property var _lastOpenedScreen: null
property int effectiveBarPosition: 0
property real effectiveBarBottomGap: 0
@@ -100,9 +102,17 @@ Item {
if (!screen)
return;
closeTimer.stop();
if (_lastOpenedScreen !== null && _lastOpenedScreen !== screen) {
contentWindow.visible = false;
if (useBackgroundWindow)
backgroundWindow.visible = false;
}
_lastOpenedScreen = screen;
shouldBeVisible = true;
Qt.callLater(() => {
if (shouldBeVisible) {
if (shouldBeVisible && screen) {
if (useBackgroundWindow)
backgroundWindow.visible = true;
contentWindow.visible = true;
+87 -72
View File
@@ -650,9 +650,10 @@ Item {
}
onWheel: wheel => {
if (!root.recording)
if (!root.recording) {
wheel.accepted = false;
return;
}
wheel.accepted = true;
const mods = [];
@@ -959,12 +960,12 @@ Item {
Layout.preferredWidth: 120
compactMode: true
currentValue: {
const action = root.editAction;
const base = root.editAction.split(" ")[0];
const cats = KeybindsService.getCompositorCategories();
for (const cat of cats) {
const actions = KeybindsService.getCompositorActions(cat);
for (const act of actions) {
if (act.id === action)
if (act.id === base)
return cat;
}
}
@@ -1024,12 +1025,13 @@ Item {
}
RowLayout {
id: optionsRow
Layout.fillWidth: true
spacing: Theme.spacingM
visible: root._actionType === "compositor" && !root.useCustomCompositor && Actions.getActionArgConfig(root.editAction)
property var argConfig: Actions.getActionArgConfig(root.editAction)
property var parsedArgs: Actions.parseCompositorActionArgs(root.editAction)
readonly property var argConfig: Actions.getActionArgConfig(root.editAction)
readonly property var parsedArgs: Actions.parseCompositorActionArgs(root.editAction)
StyledText {
text: I18n.tr("Options")
@@ -1048,56 +1050,75 @@ Item {
Layout.fillWidth: true
Layout.preferredHeight: 40
visible: {
const cfg = parent.parent.argConfig;
if (!cfg || !cfg.config || !cfg.config.args)
const cfg = optionsRow.argConfig;
if (!cfg?.config?.args)
return false;
const firstArg = cfg.config.args[0];
return firstArg && (firstArg.type === "text" || firstArg.type === "number");
}
placeholderText: {
const cfg = parent.parent.argConfig;
if (!cfg || !cfg.config || !cfg.config.args)
return "";
return cfg.config.args[0]?.placeholder || "";
placeholderText: optionsRow.argConfig?.config?.args?.[0]?.placeholder || ""
Connections {
target: optionsRow
function onParsedArgsChanged() {
const newText = optionsRow.parsedArgs?.args?.value || optionsRow.parsedArgs?.args?.index || "";
if (argValueField.text !== newText)
argValueField.text = newText;
}
}
text: parent.parent.parsedArgs?.args?.value || parent.parent.parsedArgs?.args?.index || ""
onTextChanged: {
const cfg = parent.parent.argConfig;
Component.onCompleted: {
text = optionsRow.parsedArgs?.args?.value || optionsRow.parsedArgs?.args?.index || "";
}
onEditingFinished: {
const cfg = optionsRow.argConfig;
if (!cfg)
return;
const base = parent.parent.parsedArgs?.base || root.editAction.split(" ")[0];
const args = cfg.config.args[0]?.type === "number" ? {
index: text
} : {
value: text
};
const parsed = optionsRow.parsedArgs;
const args = {};
if (cfg.config.args[0]?.type === "number")
args.index = text;
else
args.value = text;
if (parsed?.args?.focus === false)
args.focus = false;
root.updateEdit({
action: Actions.buildCompositorAction(base, args)
action: Actions.buildCompositorAction(parsed?.base || cfg.base, args)
});
}
}
RowLayout {
visible: {
const cfg = parent.parent.argConfig;
return cfg && cfg.base === "move-column-to-workspace";
const cfg = optionsRow.argConfig;
if (!cfg)
return false;
switch (cfg.base) {
case "move-column-to-workspace":
case "move-column-to-workspace-down":
case "move-column-to-workspace-up":
return true;
}
return false;
}
spacing: Theme.spacingXS
DankToggle {
id: focusToggle
checked: parent.parent.parent.parsedArgs?.args?.focus === true
onCheckedChanged: {
const cfg = parent.parent.parent.argConfig;
checked: optionsRow.parsedArgs?.args?.focus !== false
onToggled: newChecked => {
const cfg = optionsRow.argConfig;
if (!cfg)
return;
const parsed = parent.parent.parent.parsedArgs;
const args = {
index: parsed?.args?.index || "",
focus: checked
};
const parsed = optionsRow.parsedArgs;
const args = {};
if (cfg.base === "move-column-to-workspace")
args.index = parsed?.args?.index || "";
if (!newChecked)
args.focus = false;
root.updateEdit({
action: Actions.buildCompositorAction("move-column-to-workspace", args)
action: Actions.buildCompositorAction(cfg.base, args)
});
}
}
@@ -1110,53 +1131,22 @@ Item {
}
RowLayout {
visible: {
const cfg = parent.parent.argConfig;
return cfg && cfg.base && cfg.base.startsWith("screenshot");
}
visible: optionsRow.argConfig?.base?.startsWith("screenshot") ?? false
spacing: Theme.spacingM
RowLayout {
spacing: Theme.spacingXS
DankToggle {
id: writeToDiskToggle
checked: parent.parent.parent.parent.parsedArgs?.args?.opts?.["write-to-disk"] === "true"
onCheckedChanged: {
const parsed = parent.parent.parent.parent.parsedArgs;
const base = parsed?.base || "screenshot";
const opts = parsed?.args?.opts || {};
opts["write-to-disk"] = checked ? "true" : "";
root.updateEdit({
action: Actions.buildCompositorAction(base, {
opts: opts
})
});
}
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
RowLayout {
spacing: Theme.spacingXS
DankToggle {
id: showPointerToggle
checked: parent.parent.parent.parent.parsedArgs?.args?.opts?.["show-pointer"] === "true"
onCheckedChanged: {
const parsed = parent.parent.parent.parent.parsedArgs;
checked: optionsRow.parsedArgs?.args?.["show-pointer"] === true
onToggled: newChecked => {
const parsed = optionsRow.parsedArgs;
const base = parsed?.base || "screenshot";
const opts = parsed?.args?.opts || {};
opts["show-pointer"] = checked ? "true" : "";
const args = Object.assign({}, parsed?.args || {});
args["show-pointer"] = newChecked;
root.updateEdit({
action: Actions.buildCompositorAction(base, {
opts: opts
})
action: Actions.buildCompositorAction(base, args)
});
}
}
@@ -1167,6 +1157,31 @@ Item {
color: Theme.surfaceVariantText
}
}
RowLayout {
visible: optionsRow.argConfig?.base !== "screenshot"
spacing: Theme.spacingXS
DankToggle {
id: writeToDiskToggle
checked: optionsRow.parsedArgs?.args?.["write-to-disk"] === true
onToggled: newChecked => {
const parsed = optionsRow.parsedArgs;
const base = parsed?.base || "screenshot-screen";
const args = Object.assign({}, parsed?.args || {});
args["write-to-disk"] = newChecked;
root.updateEdit({
action: Actions.buildCompositorAction(base, args)
});
}
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
}
}
+78 -40
View File
@@ -17,12 +17,19 @@ Item {
property color playheadColor: Theme.primary
property real dpr: (root.window ? root.window.devicePixelRatio : 1)
function snap(v) { return Math.round(v * dpr) / dpr }
function snap(v) {
return Math.round(v * dpr) / dpr;
}
readonly property real playX: snap(root.width * root.value)
readonly property real midY: snap(height / 2)
Behavior on currentAmp { NumberAnimation { duration: 300; easing.type: Easing.OutCubic } }
Behavior on currentAmp {
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
onIsPlayingChanged: currentAmp = isPlaying ? amp : 0
Shape {
@@ -38,8 +45,16 @@ Item {
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathMove { id: flatStart; x: 0; y: root.midY }
PathLine { id: flatEnd; x: root.width; y: root.midY }
PathMove {
id: flatStart
x: Math.min(root.width, snap(root.playX + playhead.width / 2))
y: root.midY
}
PathLine {
id: flatEnd
x: root.width
y: root.midY
}
}
}
@@ -48,7 +63,7 @@ Item {
anchors.fill: parent
clip: true
readonly property real startX: snap(root.lineWidth/2)
readonly property real startX: snap(root.lineWidth / 2)
readonly property real aaBias: (0.25 / root.dpr)
readonly property real endX: Math.max(startX, Math.min(root.playX - startX - aaBias, width))
@@ -77,7 +92,10 @@ Item {
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathSvg { id: waveSvg; path: "" }
PathSvg {
id: waveSvg
path: ""
}
}
}
}
@@ -88,8 +106,8 @@ Item {
height: snap(root.lineWidth)
radius: width / 2
color: root.fillColor
x: waveClip.startX - width/2
y: root.midY - height/2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase)
x: waveClip.startX - width / 2
y: root.midY - height / 2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase)
visible: waveClip.endX > waveClip.startX
z: 2
}
@@ -100,8 +118,8 @@ Item {
height: snap(root.lineWidth)
radius: width / 2
color: root.fillColor
x: waveClip.endX - width/2
y: root.midY - height/2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase)
x: waveClip.endX - width / 2
y: root.midY - height / 2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase)
visible: waveClip.endX > waveClip.startX
z: 2
}
@@ -119,48 +137,68 @@ Item {
}
property real k: (2 * Math.PI) / Math.max(1e-6, wavelength)
function wrapMod(a, m) { let r = a % m; return r < 0 ? r + m : r }
function wrapMod(a, m) {
let r = a % m;
return r < 0 ? r + m : r;
}
readonly property real waveOffsetX: -wrapMod(phase / k, wavelength)
FrameAnimation {
running: root.visible && (root.isPlaying || root.currentAmp > 0)
onTriggered: {
if (root.isPlaying) root.phase += 0.03 * frameTime * 60
startCap.y = root.midY - startCap.height/2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase)
endCap.y = root.midY - endCap.height/2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase)
if (root.isPlaying)
root.phase += 0.03 * frameTime * 60;
startCap.y = root.midY - startCap.height / 2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase);
endCap.y = root.midY - endCap.height / 2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase);
}
}
function buildStaticWave() {
const start = waveClip.startX - 2 * root.wavelength
const end = width + 2 * root.wavelength
if (end <= start) { waveSvg.path = ""; return }
const kLocal = k
const halfPeriod = root.wavelength / 2
function y0(x) { return root.midY + root.currentAmp * Math.sin(kLocal * x) }
function dy0(x) { return root.currentAmp * Math.cos(kLocal * x) * kLocal }
let x0 = start
let d = `M ${x0} ${y0(x0)}`
while (x0 < end) {
const x1 = Math.min(x0 + halfPeriod, end)
const dx = x1 - x0
const yA = y0(x0), yB = y0(x1)
const dyA = dy0(x0), dyB = dy0(x1)
const c1x = x0 + dx/3
const c1y = yA + (dyA * dx)/3
const c2x = x1 - dx/3
const c2y = yB - (dyB * dx)/3
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}`
x0 = x1
const start = waveClip.startX - 2 * root.wavelength;
const end = width + 2 * root.wavelength;
if (end <= start) {
waveSvg.path = "";
return;
}
waveSvg.path = d
const kLocal = k;
const halfPeriod = root.wavelength / 2;
function y0(x) {
return root.midY + root.currentAmp * Math.sin(kLocal * x);
}
function dy0(x) {
return root.currentAmp * Math.cos(kLocal * x) * kLocal;
}
let x0 = start;
let d = `M ${x0} ${y0(x0)}`;
while (x0 < end) {
const x1 = Math.min(x0 + halfPeriod, end);
const dx = x1 - x0;
const yA = y0(x0), yB = y0(x1);
const dyA = dy0(x0), dyB = dy0(x1);
const c1x = x0 + dx / 3;
const c1y = yA + (dyA * dx) / 3;
const c2x = x1 - dx / 3;
const c2y = yB - (dyB * dx) / 3;
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}`;
x0 = x1;
}
waveSvg.path = d;
}
Component.onCompleted: { currentAmp = isPlaying ? amp : 0; buildStaticWave() }
onWidthChanged: { flatStart.x = 0; flatEnd.x = width; buildStaticWave() }
Component.onCompleted: {
currentAmp = isPlaying ? amp : 0;
buildStaticWave();
}
onWidthChanged: {
flatEnd.x = width;
buildStaticWave();
}
onHeightChanged: buildStaticWave()
onCurrentAmpChanged: buildStaticWave()
onWavelengthChanged: { k = (2 * Math.PI) / Math.max(1e-6, wavelength); buildStaticWave() }
onWavelengthChanged: {
k = (2 * Math.PI) / Math.max(1e-6, wavelength);
buildStaticWave();
}
}
Binary file not shown.
+16 -16
View File
@@ -11,21 +11,21 @@ text = '{{colors.background.default.hex}}'
cursor = '{{colors.primary.default.hex}}'
[colors.normal]
black = '{{dank16.color0.hex}}'
red = '{{dank16.color1.hex}}'
green = '{{dank16.color2.hex}}'
yellow = '{{dank16.color3.hex}}'
blue = '{{dank16.color4.hex}}'
magenta = '{{dank16.color5.hex}}'
cyan = '{{dank16.color6.hex}}'
white = '{{dank16.color7.hex}}'
black = '{{dank16.color0.default.hex}}'
red = '{{dank16.color1.default.hex}}'
green = '{{dank16.color2.default.hex}}'
yellow = '{{dank16.color3.default.hex}}'
blue = '{{dank16.color4.default.hex}}'
magenta = '{{dank16.color5.default.hex}}'
cyan = '{{dank16.color6.default.hex}}'
white = '{{dank16.color7.default.hex}}'
[colors.bright]
black = '{{dank16.color8.hex}}'
red = '{{dank16.color9.hex}}'
green = '{{dank16.color10.hex}}'
yellow = '{{dank16.color11.hex}}'
blue = '{{dank16.color12.hex}}'
magenta = '{{dank16.color13.hex}}'
cyan = '{{dank16.color14.hex}}'
white = '{{dank16.color15.hex}}'
black = '{{dank16.color8.default.hex}}'
red = '{{dank16.color9.default.hex}}'
green = '{{dank16.color10.default.hex}}'
yellow = '{{dank16.color11.default.hex}}'
blue = '{{dank16.color12.default.hex}}'
magenta = '{{dank16.color13.default.hex}}'
cyan = '{{dank16.color14.default.hex}}'
white = '{{dank16.color15.default.hex}}'
+16 -16
View File
@@ -4,19 +4,19 @@ background={{colors.background.default.hex_stripped}}
selection-foreground={{colors.on_surface.default.hex_stripped}}
selection-background={{colors.primary_container.default.hex_stripped}}
regular0={{dank16.color0.hex_stripped}}
regular1={{dank16.color1.hex_stripped}}
regular2={{dank16.color2.hex_stripped}}
regular3={{dank16.color3.hex_stripped}}
regular4={{dank16.color4.hex_stripped}}
regular5={{dank16.color5.hex_stripped}}
regular6={{dank16.color6.hex_stripped}}
regular7={{dank16.color7.hex_stripped}}
bright0={{dank16.color8.hex_stripped}}
bright1={{dank16.color9.hex_stripped}}
bright2={{dank16.color10.hex_stripped}}
bright3={{dank16.color11.hex_stripped}}
bright4={{dank16.color12.hex_stripped}}
bright5={{dank16.color13.hex_stripped}}
bright6={{dank16.color14.hex_stripped}}
bright7={{dank16.color15.hex_stripped}}
regular0={{dank16.color0.default.hex_stripped}}
regular1={{dank16.color1.default.hex_stripped}}
regular2={{dank16.color2.default.hex_stripped}}
regular3={{dank16.color3.default.hex_stripped}}
regular4={{dank16.color4.default.hex_stripped}}
regular5={{dank16.color5.default.hex_stripped}}
regular6={{dank16.color6.default.hex_stripped}}
regular7={{dank16.color7.default.hex_stripped}}
bright0={{dank16.color8.default.hex_stripped}}
bright1={{dank16.color9.default.hex_stripped}}
bright2={{dank16.color10.default.hex_stripped}}
bright3={{dank16.color11.default.hex_stripped}}
bright4={{dank16.color12.default.hex_stripped}}
bright5={{dank16.color13.default.hex_stripped}}
bright6={{dank16.color14.default.hex_stripped}}
bright7={{dank16.color15.default.hex_stripped}}

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