1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

Compare commits

...

20 Commits

Author SHA1 Message Date
bbedward
1926db95de dankinstall fix plasma session collision 2025-12-26 13:14:01 -05:00
bbedward
5ad2a9d704 remove tests from master 2025-12-15 23:59:25 -05:00
Lucas
e0ab20dbda nix: fix greeter per-monitor and per-mode wallpapers (#974) 2025-12-15 22:47:42 -05:00
bbedward
aadc3111a2 fix undefined modal warnings 2025-12-15 22:06:54 -05:00
bbedward
741d492084 v1.0.3 2025-12-15 21:32:07 -05:00
bbedward
604d55015c gamma: guard against application - QML will sync its desired state with GO, when IE settings are changed or opened. Go was applying gamma even if unchanged - Track last applied gamma to avoid sends 2025-12-15 21:31:45 -05:00
bbedward
a4ce39caa5 core: add test coverage for some of the wayland stack - mostly targeting any race issue detection 2025-12-15 21:31:22 -05:00
tsukasa
0a82c9877d dankmodal: removed backgroundWindow to fix clicking twice (#1030)
* dankmodal: removed backgroundWindow

removed 'backgroundWindow' but combined it with 'contentWindow'

* made single window behavior specific to hyprland

this should keep other compositor behavior the same and fix double
clicking to exit out of Spotlight/ClipboardHist/Powermenu
2025-12-15 21:28:15 -05:00
tsukasa
56f5c5eccb Fixed having to click twice to exit out of Spotlight/Cliphist/Powermenu (#1022)
There's possibly more but this fix the need of having to click the
background twice to close those modals.
2025-12-15 21:28:04 -05:00
bbedward
d20b5adbfa battery: fix button group sclaing 2025-12-15 21:27:29 -05:00
bbedward
10dc86a5dc vpn: optim cc and dankbar widget 2025-12-15 21:27:15 -05:00
bbedward
5463aed213 binds: fix to scale with arbitrary font sizes 2025-12-15 21:19:12 -05:00
bbedward
f435f0d413 dwl: fix layout popout 2025-12-15 21:18:40 -05:00
Souyama
521d804763 Change DPMS off to DPMS toggle in hyprland.conf (#1011) 2025-12-15 21:18:12 -05:00
bbedward
e203ec960a cava: dont set method/source 2025-12-15 21:17:39 -05:00
bbedward
830ca10b45 vpn: just try and import all types on errors 2025-12-15 21:17:00 -05:00
bbedward
4ffa06945a wallpaper: scale texture to physical pixels - reverts a regression 2025-12-15 21:16:43 -05:00
bbedward
b1406fc49a matugen: scrub the never implemented dynamic contrast palette 2025-12-15 21:16:20 -05:00
bbedward
f8179167a8 niri: fix gap reactivity 2025-12-15 21:15:47 -05:00
bbedward
32998a5219 wallpaper: clamp max texture size 2025-12-15 21:14:46 -05:00
29 changed files with 2230 additions and 619 deletions

View File

@@ -0,0 +1,314 @@
package colorpicker
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSurfaceState_ConcurrentPointerMotion(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
var wg sync.WaitGroup
const goroutines = 50
const iterations = 100
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
s.OnPointerMotion(float64(id*10+j), float64(id*10+j))
}
}(i)
}
wg.Wait()
}
func TestSurfaceState_ConcurrentScaleAccess(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
var wg sync.WaitGroup
const goroutines = 30
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
s.SetScale(int32(id%3 + 1))
}
}(i)
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
scale := s.Scale()
assert.GreaterOrEqual(t, scale, int32(1))
}
}()
}
wg.Wait()
}
func TestSurfaceState_ConcurrentLogicalSize(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
var wg sync.WaitGroup
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
_ = s.OnLayerConfigure(1920+id, 1080+j)
}
}(i)
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
w, h := s.LogicalSize()
_ = w
_ = h
}
}()
}
wg.Wait()
}
func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
var wg sync.WaitGroup
const goroutines = 30
const iterations = 100
for i := 0; i < goroutines/3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
s.OnPointerButton(0x110, 1)
}
}()
}
for i := 0; i < goroutines/3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
s.OnKey(1, 1)
}
}()
}
for i := 0; i < goroutines/3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
picked, cancelled := s.IsDone()
_ = picked
_ = cancelled
}
}()
}
wg.Wait()
}
func TestSurfaceState_ConcurrentIsReady(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
var wg sync.WaitGroup
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
_ = s.IsReady()
}
}()
}
wg.Wait()
}
func TestSurfaceState_ConcurrentSwapBuffers(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
var wg sync.WaitGroup
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
s.SwapBuffers()
}
}()
}
wg.Wait()
}
func TestSurfaceState_ZeroScale(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
s.SetScale(0)
assert.Equal(t, int32(1), s.Scale())
}
func TestSurfaceState_NegativeScale(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
s.SetScale(-5)
assert.Equal(t, int32(1), s.Scale())
}
func TestSurfaceState_ZeroDimensionConfigure(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
err := s.OnLayerConfigure(0, 100)
assert.NoError(t, err)
err = s.OnLayerConfigure(100, 0)
assert.NoError(t, err)
err = s.OnLayerConfigure(-1, 100)
assert.NoError(t, err)
w, h := s.LogicalSize()
assert.Equal(t, 0, w)
assert.Equal(t, 0, h)
}
func TestSurfaceState_PickColorNilBuffer(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
color, ok := s.PickColor()
assert.False(t, ok)
assert.Equal(t, Color{}, color)
}
func TestSurfaceState_RedrawNilBuffer(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
buf := s.Redraw()
assert.Nil(t, buf)
}
func TestSurfaceState_RedrawScreenOnlyNilBuffer(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
buf := s.RedrawScreenOnly()
assert.Nil(t, buf)
}
func TestSurfaceState_FrontRenderBufferNil(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
buf := s.FrontRenderBuffer()
assert.Nil(t, buf)
}
func TestSurfaceState_ScreenBufferNil(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
buf := s.ScreenBuffer()
assert.Nil(t, buf)
}
func TestSurfaceState_DestroyMultipleTimes(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
s.Destroy()
s.Destroy()
}
func TestClamp(t *testing.T) {
tests := []struct {
v, lo, hi, expected int
}{
{5, 0, 10, 5},
{-5, 0, 10, 0},
{15, 0, 10, 10},
{0, 0, 10, 0},
{10, 0, 10, 10},
}
for _, tt := range tests {
result := clamp(tt.v, tt.lo, tt.hi)
assert.Equal(t, tt.expected, result)
}
}
func TestClampF(t *testing.T) {
tests := []struct {
v, lo, hi, expected float64
}{
{5.0, 0.0, 10.0, 5.0},
{-5.0, 0.0, 10.0, 0.0},
{15.0, 0.0, 10.0, 10.0},
{0.0, 0.0, 10.0, 0.0},
{10.0, 0.0, 10.0, 10.0},
}
for _, tt := range tests {
result := clampF(tt.v, tt.lo, tt.hi)
assert.InDelta(t, tt.expected, result, 0.001)
}
}
func TestAbs(t *testing.T) {
tests := []struct {
v, expected int
}{
{5, 5},
{-5, 5},
{0, 0},
}
for _, tt := range tests {
result := abs(tt.v)
assert.Equal(t, tt.expected, result)
}
}
func TestBlendColors(t *testing.T) {
bg := Color{R: 0, G: 0, B: 0, A: 255}
fg := Color{R: 255, G: 255, B: 255, A: 255}
result := blendColors(bg, fg, 0.0)
assert.Equal(t, bg.R, result.R)
assert.Equal(t, bg.G, result.G)
assert.Equal(t, bg.B, result.B)
result = blendColors(bg, fg, 1.0)
assert.Equal(t, fg.R, result.R)
assert.Equal(t, fg.G, result.G)
assert.Equal(t, fg.B, result.B)
result = blendColors(bg, fg, 0.5)
assert.InDelta(t, 127, int(result.R), 1)
assert.InDelta(t, 127, int(result.G), 1)
assert.InDelta(t, 127, int(result.B), 1)
result = blendColors(bg, fg, -1.0)
assert.Equal(t, bg.R, result.R)
result = blendColors(bg, fg, 2.0)
assert.Equal(t, fg.R, result.R)
}

View File

@@ -277,4 +277,4 @@ bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = $mod SHIFT, P, dpms, off
bind = $mod SHIFT, P, dpms, toggle

View File

@@ -14,6 +14,7 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
)
@@ -581,12 +582,20 @@ func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error
terminalCmd = "ghostty"
}
content := fmt.Sprintf(`QT_QPA_PLATFORM=wayland
// ! This deviates from master branch so it doesnt need a hotfix
var content string
if utils.CommandExists("plasmashell") || utils.CommandExists("plasma-session") || utils.CommandExists("plasma_session") {
content = fmt.Sprintf(`ELECTRON_OZONE_PLATFORM_HINT=auto
TERMINAL=%s
`, terminalCmd)
} else {
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 {
@@ -598,12 +607,6 @@ TERMINAL=%s
}
func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) 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")
switch wm {
case deps.WindowManagerNiri:
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").Run(); err != nil {

View File

@@ -0,0 +1,352 @@
package dwl
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestStateChanged_BothNil(t *testing.T) {
assert.True(t, stateChanged(nil, nil))
}
func TestStateChanged_OneNil(t *testing.T) {
s := &State{TagCount: 9}
assert.True(t, stateChanged(s, nil))
assert.True(t, stateChanged(nil, s))
}
func TestStateChanged_TagCountDiffers(t *testing.T) {
a := &State{TagCount: 9, Outputs: make(map[string]*OutputState), Layouts: []string{}}
b := &State{TagCount: 10, Outputs: make(map[string]*OutputState), Layouts: []string{}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_LayoutLengthDiffers(t *testing.T) {
a := &State{TagCount: 9, Layouts: []string{"tile"}, Outputs: make(map[string]*OutputState)}
b := &State{TagCount: 9, Layouts: []string{"tile", "monocle"}, Outputs: make(map[string]*OutputState)}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_ActiveOutputDiffers(t *testing.T) {
a := &State{TagCount: 9, ActiveOutput: "eDP-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
b := &State{TagCount: 9, ActiveOutput: "HDMI-A-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputCountDiffers(t *testing.T) {
a := &State{
TagCount: 9,
Outputs: map[string]*OutputState{"eDP-1": {}},
Layouts: []string{},
}
b := &State{
TagCount: 9,
Outputs: map[string]*OutputState{},
Layouts: []string{},
}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputFieldsDiffer(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Active: 1, Layout: 0, Title: "Firefox"},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Active: 0, Layout: 0, Title: "Firefox"},
},
}
assert.True(t, stateChanged(a, b))
b.Outputs["eDP-1"].Active = 1
b.Outputs["eDP-1"].Layout = 1
assert.True(t, stateChanged(a, b))
b.Outputs["eDP-1"].Layout = 0
b.Outputs["eDP-1"].Title = "Code"
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_TagsDiffer(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1, State: 2, Clients: 2, Focused: 1}}},
},
}
assert.True(t, stateChanged(a, b))
b.Outputs["eDP-1"].Tags[0].State = 1
b.Outputs["eDP-1"].Tags[0].Clients = 3
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_Equal(t *testing.T) {
a := &State{
TagCount: 9,
ActiveOutput: "eDP-1",
Layouts: []string{"tile", "monocle"},
Outputs: map[string]*OutputState{
"eDP-1": {
Name: "eDP-1",
Active: 1,
Layout: 0,
LayoutSymbol: "[]=",
Title: "Firefox",
AppID: "firefox",
KbLayout: "us",
Keymode: "",
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
},
},
}
b := &State{
TagCount: 9,
ActiveOutput: "eDP-1",
Layouts: []string{"tile", "monocle"},
Outputs: map[string]*OutputState{
"eDP-1": {
Name: "eDP-1",
Active: 1,
Layout: 0,
LayoutSymbol: "[]=",
Title: "Firefox",
AppID: "firefox",
KbLayout: "us",
Keymode: "",
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
},
},
}
assert.False(t, stateChanged(a, b))
}
func TestManager_ConcurrentGetState(t *testing.T) {
m := &Manager{
state: &State{
TagCount: 9,
Layouts: []string{"tile"},
Outputs: map[string]*OutputState{"eDP-1": {Name: "eDP-1"}},
},
}
var wg sync.WaitGroup
const goroutines = 50
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
s := m.GetState()
_ = s.TagCount
_ = s.Outputs
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.stateMutex.Lock()
m.state = &State{
TagCount: uint32(j % 10),
Layouts: []string{"tile", "monocle"},
Outputs: map[string]*OutputState{"eDP-1": {Active: uint32(j % 2)}},
}
m.stateMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
stopChan: make(chan struct{}),
dirty: make(chan struct{}, 1),
}
var wg sync.WaitGroup
const goroutines = 20
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
subID := string(rune('a' + id))
ch := m.Subscribe(subID)
assert.NotNil(t, ch)
time.Sleep(time.Millisecond)
m.Unsubscribe(subID)
}(i)
}
wg.Wait()
}
func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
m := &Manager{}
var wg sync.WaitGroup
const goroutines = 30
const iterations = 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
key := uint32(id)
for j := 0; j < iterations; j++ {
state := &outputState{
id: key,
name: "test-output",
active: uint32(j % 2),
tags: []TagState{{Tag: uint32(j), State: 1}},
}
m.outputs.Store(key, state)
if loaded, ok := m.outputs.Load(key); ok {
assert.Equal(t, key, loaded.id)
}
m.outputs.Range(func(k uint32, v *outputState) bool {
_ = v.name
_ = v.active
return true
})
}
m.outputs.Delete(key)
}(i)
}
wg.Wait()
}
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
m := &Manager{
dirty: make(chan struct{}, 1),
}
for i := 0; i < 10; i++ {
m.notifySubscribers()
}
assert.Len(t, m.dirty, 1)
}
func TestManager_PostQueueFull(t *testing.T) {
m := &Manager{
cmdq: make(chan cmd, 2),
stopChan: make(chan struct{}),
}
m.post(func() {})
m.post(func() {})
m.post(func() {})
m.post(func() {})
assert.Len(t, m.cmdq, 2)
}
func TestManager_GetStateNilState(t *testing.T) {
m := &Manager{}
s := m.GetState()
assert.NotNil(t, s.Outputs)
assert.NotNil(t, s.Layouts)
assert.Equal(t, uint32(0), s.TagCount)
}
func TestTagState_Fields(t *testing.T) {
tag := TagState{
Tag: 1,
State: 2,
Clients: 3,
Focused: 1,
}
assert.Equal(t, uint32(1), tag.Tag)
assert.Equal(t, uint32(2), tag.State)
assert.Equal(t, uint32(3), tag.Clients)
assert.Equal(t, uint32(1), tag.Focused)
}
func TestOutputState_Fields(t *testing.T) {
out := OutputState{
Name: "eDP-1",
Active: 1,
Tags: []TagState{{Tag: 1}},
Layout: 0,
LayoutSymbol: "[]=",
Title: "Firefox",
AppID: "firefox",
KbLayout: "us",
Keymode: "",
}
assert.Equal(t, "eDP-1", out.Name)
assert.Equal(t, uint32(1), out.Active)
assert.Len(t, out.Tags, 1)
assert.Equal(t, "[]=", out.LayoutSymbol)
}
func TestStateChanged_NewOutputAppears(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Name: "eDP-1"},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Name: "eDP-1"},
"HDMI-A-1": {Name: "HDMI-A-1"},
},
}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_TagsLengthDiffers(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1}}},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1}, {Tag: 2}}},
},
}
assert.True(t, stateChanged(a, b))
}

View File

@@ -880,29 +880,24 @@ func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImp
}
func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) {
args := []string{"connection", "import", "type", "openvpn", "file", filePath}
cmd := exec.Command("nmcli", args...)
output, err := cmd.CombinedOutput()
vpnTypes := []string{"openvpn", "wireguard", "vpnc", "pptp", "l2tp", "openconnect", "strongswan"}
var output []byte
var err error
for _, vpnType := range vpnTypes {
args := []string{"connection", "import", "type", vpnType, "file", filePath}
cmd := exec.Command("nmcli", args...)
output, err = cmd.CombinedOutput()
if err == nil {
break
}
}
if err != nil {
outputStr := string(output)
if strings.Contains(outputStr, "vpnc") || strings.Contains(outputStr, "unknown connection type") {
for _, vpnType := range []string{"vpnc", "pptp", "l2tp", "openconnect", "strongswan", "wireguard"} {
args = []string{"connection", "import", "type", vpnType, "file", filePath}
cmd = exec.Command("nmcli", args...)
output, err = cmd.CombinedOutput()
if err == nil {
break
}
}
}
if err != nil {
return &VPNImportResult{
Success: false,
Error: fmt.Sprintf("import failed: %s", strings.TrimSpace(string(output))),
}, nil
}
return &VPNImportResult{
Success: false,
Error: fmt.Sprintf("import failed: %s", strings.TrimSpace(string(output))),
}, nil
}
outputStr := string(output)

View File

@@ -103,18 +103,16 @@ func (m *Manager) waylandActor() {
}
}
func (m *Manager) allOutputsReady() bool {
hasOutputs := false
allReady := true
func (m *Manager) anyOutputReady() bool {
anyReady := false
m.outputs.Range(func(_ uint32, out *outputState) bool {
hasOutputs = true
if out.rampSize == 0 || out.failed {
allReady = false
return false
if out.rampSize > 0 && !out.failed {
anyReady = true
return false // stop iteration
}
return true
})
return hasOutputs && allReady
return anyReady
}
func (m *Manager) setupDBusMonitor() error {
@@ -268,31 +266,37 @@ func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_g
}
func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) {
outputID := state.id
control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
if out, ok := m.outputs.Load(state.id); ok {
out.rampSize = e.Size
out.failed = false
out.retryCount = 0
}
size := e.Size
m.post(func() {
m.applyCurrentTemp()
if out, ok := m.outputs.Load(outputID); ok {
out.rampSize = size
out.failed = false
out.retryCount = 0
}
m.lastAppliedTemp = 0
m.applyCurrentTemp("gamma_size")
})
})
control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
out, ok := m.outputs.Load(state.id)
if !ok {
return
}
out.failed = true
out.rampSize = 0
out.retryCount++
out.lastFailTime = time.Now()
m.post(func() {
out, ok := m.outputs.Load(outputID)
if !ok {
return
}
out.failed = true
out.rampSize = 0
out.retryCount++
out.lastFailTime = time.Now()
backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
time.AfterFunc(backoff, func() {
m.post(func() {
m.recreateOutputControl(out)
backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
time.AfterFunc(backoff, func() {
m.post(func() {
m.recreateOutputControl(out)
})
})
})
})
@@ -523,8 +527,9 @@ func (m *Manager) getNextDeadline(now time.Time) time.Time {
return m.tomorrow(now)
case StateNormal:
return m.getDeadlineNormal(now, sched)
default:
return m.tomorrow(now)
}
return m.tomorrow(now)
}
func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
@@ -583,7 +588,7 @@ func (m *Manager) schedulerLoop() {
m.configMutex.RUnlock()
if enabled {
m.applyCurrentTemp()
m.post(func() { m.applyCurrentTemp("startup") })
}
var timer *time.Timer
@@ -625,24 +630,27 @@ func (m *Manager) schedulerLoop() {
enabled := m.config.Enabled
m.configMutex.RUnlock()
if enabled {
m.applyCurrentTemp()
m.post(func() { m.applyCurrentTemp("updateTrigger") })
}
case <-timer.C:
m.configMutex.RLock()
enabled := m.config.Enabled
m.configMutex.RUnlock()
if enabled {
m.applyCurrentTemp()
m.post(func() { m.applyCurrentTemp("timer") })
}
}
}
}
func (m *Manager) applyCurrentTemp() {
if !m.controlsInitialized || !m.allOutputsReady() {
func (m *Manager) applyCurrentTemp(_ string) {
if !m.controlsInitialized || !m.anyOutputReady() {
return
}
// Ensure schedule is up-to-date (handles display wake after overnight sleep)
m.recalcSchedule(time.Now())
m.configMutex.RLock()
low, high := m.config.LowTemp, m.config.HighTemp
m.configMutex.RUnlock()
@@ -675,6 +683,10 @@ func (m *Manager) applyGamma(temp int) {
return
}
if m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma {
return
}
var outs []*outputState
m.outputs.Range(func(_ uint32, out *outputState) bool {
outs = append(outs, out)
@@ -710,6 +722,7 @@ func (m *Manager) applyGamma(temp int) {
for _, j := range jobs {
if err := m.setGammaBytes(j.out, j.data); err != nil {
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
j.out.failed = true
j.out.rampSize = 0
outID := j.out.id
@@ -722,6 +735,9 @@ func (m *Manager) applyGamma(temp int) {
})
}
}
m.lastAppliedTemp = temp
m.lastAppliedGamma = gamma
}
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
@@ -896,6 +912,10 @@ func (m *Manager) SetConfig(config Config) error {
func (m *Manager) SetTemperature(low, high int) error {
m.configMutex.Lock()
if m.config.LowTemp == low && m.config.HighTemp == high {
m.configMutex.Unlock()
return nil
}
m.config.LowTemp = low
m.config.HighTemp = high
err := m.config.Validate()
@@ -909,6 +929,11 @@ func (m *Manager) SetTemperature(low, high int) error {
func (m *Manager) SetLocation(lat, lon float64) error {
m.configMutex.Lock()
if m.config.Latitude != nil && m.config.Longitude != nil &&
*m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation {
m.configMutex.Unlock()
return nil
}
m.config.Latitude = &lat
m.config.Longitude = &lon
m.config.UseIPLocation = false
@@ -923,6 +948,10 @@ func (m *Manager) SetLocation(lat, lon float64) error {
func (m *Manager) SetUseIPLocation(use bool) {
m.configMutex.Lock()
if m.config.UseIPLocation == use {
m.configMutex.Unlock()
return
}
m.config.UseIPLocation = use
if use {
m.config.Latitude = nil
@@ -941,6 +970,12 @@ func (m *Manager) SetUseIPLocation(use bool) {
func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
m.configMutex.Lock()
if m.config.ManualSunrise != nil && m.config.ManualSunset != nil &&
m.config.ManualSunrise.Hour() == sunrise.Hour() && m.config.ManualSunrise.Minute() == sunrise.Minute() &&
m.config.ManualSunset.Hour() == sunset.Hour() && m.config.ManualSunset.Minute() == sunset.Minute() {
m.configMutex.Unlock()
return nil
}
m.config.ManualSunrise = &sunrise
m.config.ManualSunset = &sunset
err := m.config.Validate()
@@ -954,6 +989,10 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
func (m *Manager) ClearManualTimes() {
m.configMutex.Lock()
if m.config.ManualSunrise == nil && m.config.ManualSunset == nil {
m.configMutex.Unlock()
return
}
m.config.ManualSunrise = nil
m.config.ManualSunset = nil
m.configMutex.Unlock()
@@ -962,6 +1001,10 @@ func (m *Manager) ClearManualTimes() {
func (m *Manager) SetGamma(gamma float64) error {
m.configMutex.Lock()
if m.config.Gamma == gamma {
m.configMutex.Unlock()
return nil
}
m.config.Gamma = gamma
err := m.config.Validate()
m.configMutex.Unlock()
@@ -975,6 +1018,10 @@ func (m *Manager) SetGamma(gamma float64) error {
func (m *Manager) SetEnabled(enabled bool) {
m.configMutex.Lock()
wasEnabled := m.config.Enabled
if wasEnabled == enabled {
m.configMutex.Unlock()
return
}
m.config.Enabled = enabled
highTemp := m.config.HighTemp
m.configMutex.Unlock()
@@ -984,7 +1031,7 @@ func (m *Manager) SetEnabled(enabled bool) {
m.post(func() {
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
if err := m.setupOutputControls(m.availableOutputs, gammaMgr); err != nil {
log.Errorf("Failed to create gamma controls: %v", err)
log.Errorf("gamma: failed to create controls: %v", err)
return
}
m.controlsInitialized = true

View File

@@ -0,0 +1,386 @@
package wayland
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
m := &Manager{
cmdq: make(chan cmd, 128),
stopChan: make(chan struct{}),
}
m.wg.Add(1)
go m.waylandActor()
state := &outputState{
id: 1,
registryName: 100,
rampSize: 256,
}
m.outputs.Store(state.id, state)
var wg sync.WaitGroup
const goroutines = 50
const iterations = 100
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.post(func() {
if out, ok := m.outputs.Load(state.id); ok {
out.rampSize = uint32(j)
out.failed = j%2 == 0
out.retryCount = j
out.lastFailTime = time.Now()
}
})
}
}(i)
}
wg.Wait()
done := make(chan struct{})
m.post(func() { close(done) })
<-done
close(m.stopChan)
m.wg.Wait()
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
stopChan: make(chan struct{}),
dirty: make(chan struct{}, 1),
updateTrigger: make(chan struct{}, 1),
}
var wg sync.WaitGroup
const goroutines = 20
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
subID := string(rune('a' + id))
ch := m.Subscribe(subID)
assert.NotNil(t, ch)
time.Sleep(time.Millisecond)
m.Unsubscribe(subID)
}(i)
}
wg.Wait()
}
func TestManager_ConcurrentGetState(t *testing.T) {
m := &Manager{
state: &State{
CurrentTemp: 5000,
IsDay: true,
},
}
var wg sync.WaitGroup
const goroutines = 50
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
s := m.GetState()
assert.GreaterOrEqual(t, s.CurrentTemp, 0)
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.stateMutex.Lock()
m.state = &State{
CurrentTemp: 4000 + i*100,
IsDay: j%2 == 0,
}
m.stateMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_ConcurrentConfigAccess(t *testing.T) {
m := &Manager{
config: DefaultConfig(),
}
var wg sync.WaitGroup
const goroutines = 30
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.configMutex.RLock()
_ = m.config.LowTemp
_ = m.config.HighTemp
_ = m.config.Enabled
m.configMutex.RUnlock()
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.configMutex.Lock()
m.config.LowTemp = 3000 + j
m.config.HighTemp = 7000 - j
m.config.Enabled = j%2 == 0
m.configMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
m := &Manager{}
var wg sync.WaitGroup
const goroutines = 30
const iterations = 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
key := uint32(id)
for j := 0; j < iterations; j++ {
state := &outputState{
id: key,
rampSize: uint32(j),
failed: j%2 == 0,
}
m.outputs.Store(key, state)
if loaded, ok := m.outputs.Load(key); ok {
assert.Equal(t, key, loaded.id)
}
m.outputs.Range(func(k uint32, v *outputState) bool {
_ = v.rampSize
_ = v.failed
return true
})
}
m.outputs.Delete(key)
}(i)
}
wg.Wait()
}
func TestManager_LocationCacheConcurrentAccess(t *testing.T) {
m := &Manager{}
var wg sync.WaitGroup
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.locationMutex.RLock()
_ = m.cachedIPLat
_ = m.cachedIPLon
m.locationMutex.RUnlock()
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
lat := float64(40 + i)
lon := float64(-74 + j)
m.locationMutex.Lock()
m.cachedIPLat = &lat
m.cachedIPLon = &lon
m.locationMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_ScheduleConcurrentAccess(t *testing.T) {
now := time.Now()
m := &Manager{
schedule: sunSchedule{
times: SunTimes{
Dawn: now,
Sunrise: now.Add(time.Hour),
Sunset: now.Add(12 * time.Hour),
Night: now.Add(13 * time.Hour),
},
},
}
var wg sync.WaitGroup
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.scheduleMutex.RLock()
_ = m.schedule.times.Dawn
_ = m.schedule.times.Sunrise
_ = m.schedule.times.Sunset
_ = m.schedule.condition
m.scheduleMutex.RUnlock()
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.scheduleMutex.Lock()
m.schedule.times.Dawn = time.Now()
m.schedule.times.Sunrise = time.Now().Add(time.Hour)
m.schedule.condition = SunNormal
m.scheduleMutex.Unlock()
}
}()
}
wg.Wait()
}
func TestInterpolate_EdgeCases(t *testing.T) {
now := time.Now()
tests := []struct {
name string
now time.Time
start time.Time
stop time.Time
expected float64
}{
{
name: "same start and stop",
now: now,
start: now,
stop: now,
expected: 1.0,
},
{
name: "now before start",
now: now,
start: now.Add(time.Hour),
stop: now.Add(2 * time.Hour),
expected: 0.0,
},
{
name: "now after stop",
now: now.Add(3 * time.Hour),
start: now,
stop: now.Add(time.Hour),
expected: 1.0,
},
{
name: "now at midpoint",
now: now.Add(30 * time.Minute),
start: now,
stop: now.Add(time.Hour),
expected: 0.5,
},
{
name: "now equals start",
now: now,
start: now,
stop: now.Add(time.Hour),
expected: 0.0,
},
{
name: "now equals stop",
now: now.Add(time.Hour),
start: now,
stop: now.Add(time.Hour),
expected: 1.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := interpolate(tt.now, tt.start, tt.stop)
assert.InDelta(t, tt.expected, result, 0.01)
})
}
}
func TestGenerateGammaRamp_ZeroSize(t *testing.T) {
ramp := GenerateGammaRamp(0, 5000, 1.0)
assert.Empty(t, ramp.Red)
assert.Empty(t, ramp.Green)
assert.Empty(t, ramp.Blue)
}
func TestGenerateGammaRamp_ValidSizes(t *testing.T) {
sizes := []uint32{1, 256, 1024}
temps := []int{1000, 4000, 6500, 10000}
gammas := []float64{0.5, 1.0, 2.0}
for _, size := range sizes {
for _, temp := range temps {
for _, gamma := range gammas {
ramp := GenerateGammaRamp(size, temp, gamma)
assert.Len(t, ramp.Red, int(size))
assert.Len(t, ramp.Green, int(size))
assert.Len(t, ramp.Blue, int(size))
}
}
}
}
func TestNotifySubscribers_NonBlocking(t *testing.T) {
m := &Manager{
dirty: make(chan struct{}, 1),
}
for i := 0; i < 10; i++ {
m.notifySubscribers()
}
assert.Len(t, m.dirty, 1)
}

View File

@@ -96,6 +96,9 @@ type Manager struct {
dbusConn *dbus.Conn
dbusSignal chan *dbus.Signal
lastAppliedTemp int
lastAppliedGamma float64
}
type outputState struct {

View File

@@ -0,0 +1,400 @@
package wlroutput
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestStateChanged_BothNil(t *testing.T) {
assert.True(t, stateChanged(nil, nil))
}
func TestStateChanged_OneNil(t *testing.T) {
s := &State{Serial: 1}
assert.True(t, stateChanged(s, nil))
assert.True(t, stateChanged(nil, s))
}
func TestStateChanged_SerialDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{}}
b := &State{Serial: 2, Outputs: []Output{}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputCountDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}}
b := &State{Serial: 1, Outputs: []Output{}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputNameDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "HDMI-A-1", Enabled: true}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputEnabledDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: false}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputPositionDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 0, Y: 0}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 1920, Y: 0}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputTransformDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 0}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 1}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputScaleDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 1.0}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 2.0}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputAdaptiveSyncDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 0}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 1}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_CurrentModeNilVsNonNil(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: &OutputMode{Width: 1920}}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_CurrentModeDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{
Name: "eDP-1",
CurrentMode: &OutputMode{Width: 1920, Height: 1080, Refresh: 60000},
}}}
b := &State{Serial: 1, Outputs: []Output{{
Name: "eDP-1",
CurrentMode: &OutputMode{Width: 2560, Height: 1440, Refresh: 60000},
}}}
assert.True(t, stateChanged(a, b))
b.Outputs[0].CurrentMode.Width = 1920
b.Outputs[0].CurrentMode.Height = 1080
b.Outputs[0].CurrentMode.Refresh = 144000
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_ModesLengthDiffers(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}}}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}, {Width: 1280}}}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_Equal(t *testing.T) {
mode := OutputMode{Width: 1920, Height: 1080, Refresh: 60000, Preferred: true}
a := &State{
Serial: 5,
Outputs: []Output{{
Name: "eDP-1",
Description: "Built-in display",
Make: "BOE",
Model: "0x0ABC",
SerialNumber: "12345",
PhysicalWidth: 309,
PhysicalHeight: 174,
Enabled: true,
X: 0,
Y: 0,
Transform: 0,
Scale: 1.0,
CurrentMode: &mode,
Modes: []OutputMode{mode},
AdaptiveSync: 0,
}},
}
b := &State{
Serial: 5,
Outputs: []Output{{
Name: "eDP-1",
Description: "Built-in display",
Make: "BOE",
Model: "0x0ABC",
SerialNumber: "12345",
PhysicalWidth: 309,
PhysicalHeight: 174,
Enabled: true,
X: 0,
Y: 0,
Transform: 0,
Scale: 1.0,
CurrentMode: &mode,
Modes: []OutputMode{mode},
AdaptiveSync: 0,
}},
}
assert.False(t, stateChanged(a, b))
}
func TestManager_ConcurrentGetState(t *testing.T) {
m := &Manager{
state: &State{
Serial: 1,
Outputs: []Output{{Name: "eDP-1", Enabled: true}},
},
}
var wg sync.WaitGroup
const goroutines = 50
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
s := m.GetState()
_ = s.Serial
_ = s.Outputs
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.stateMutex.Lock()
m.state = &State{
Serial: uint32(j),
Outputs: []Output{{Name: "eDP-1", Scale: float64(j % 3)}},
}
m.stateMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
stopChan: make(chan struct{}),
dirty: make(chan struct{}, 1),
}
var wg sync.WaitGroup
const goroutines = 20
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
subID := string(rune('a' + id))
ch := m.Subscribe(subID)
assert.NotNil(t, ch)
time.Sleep(time.Millisecond)
m.Unsubscribe(subID)
}(i)
}
wg.Wait()
}
func TestManager_SyncmapHeadsConcurrentAccess(t *testing.T) {
m := &Manager{}
var wg sync.WaitGroup
const goroutines = 30
const iterations = 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
key := uint32(id)
for j := 0; j < iterations; j++ {
state := &headState{
id: key,
name: "test-head",
enabled: j%2 == 0,
scale: float64(j % 3),
modeIDs: []uint32{uint32(j)},
}
m.heads.Store(key, state)
if loaded, ok := m.heads.Load(key); ok {
assert.Equal(t, key, loaded.id)
}
m.heads.Range(func(k uint32, v *headState) bool {
_ = v.name
_ = v.enabled
return true
})
}
m.heads.Delete(key)
}(i)
}
wg.Wait()
}
func TestManager_SyncmapModesConcurrentAccess(t *testing.T) {
m := &Manager{}
var wg sync.WaitGroup
const goroutines = 30
const iterations = 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
key := uint32(id)
for j := 0; j < iterations; j++ {
state := &modeState{
id: key,
width: int32(1920 + j),
height: int32(1080 + j),
refresh: 60000,
preferred: j == 0,
}
m.modes.Store(key, state)
if loaded, ok := m.modes.Load(key); ok {
assert.Equal(t, key, loaded.id)
}
m.modes.Range(func(k uint32, v *modeState) bool {
_ = v.width
_ = v.height
return true
})
}
m.modes.Delete(key)
}(i)
}
wg.Wait()
}
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
m := &Manager{
dirty: make(chan struct{}, 1),
}
for i := 0; i < 10; i++ {
m.notifySubscribers()
}
assert.Len(t, m.dirty, 1)
}
func TestManager_PostQueueFull(t *testing.T) {
m := &Manager{
cmdq: make(chan cmd, 2),
stopChan: make(chan struct{}),
}
m.post(func() {})
m.post(func() {})
m.post(func() {})
m.post(func() {})
assert.Len(t, m.cmdq, 2)
}
func TestManager_GetStateNilState(t *testing.T) {
m := &Manager{}
s := m.GetState()
assert.NotNil(t, s.Outputs)
assert.Equal(t, uint32(0), s.Serial)
}
func TestManager_FatalErrorChannel(t *testing.T) {
m := &Manager{
fatalError: make(chan error, 1),
}
ch := m.FatalError()
assert.NotNil(t, ch)
m.fatalError <- assert.AnError
err := <-ch
assert.Error(t, err)
}
func TestOutputMode_Fields(t *testing.T) {
mode := OutputMode{
Width: 1920,
Height: 1080,
Refresh: 60000,
Preferred: true,
ID: 42,
}
assert.Equal(t, int32(1920), mode.Width)
assert.Equal(t, int32(1080), mode.Height)
assert.Equal(t, int32(60000), mode.Refresh)
assert.True(t, mode.Preferred)
assert.Equal(t, uint32(42), mode.ID)
}
func TestOutput_Fields(t *testing.T) {
out := Output{
Name: "eDP-1",
Description: "Built-in display",
Make: "BOE",
Model: "0x0ABC",
SerialNumber: "12345",
PhysicalWidth: 309,
PhysicalHeight: 174,
Enabled: true,
X: 0,
Y: 0,
Transform: 0,
Scale: 1.5,
AdaptiveSync: 1,
ID: 1,
}
assert.Equal(t, "eDP-1", out.Name)
assert.Equal(t, "Built-in display", out.Description)
assert.True(t, out.Enabled)
assert.Equal(t, float64(1.5), out.Scale)
assert.Equal(t, uint32(1), out.AdaptiveSync)
}
func TestHeadState_ModeIDsSlice(t *testing.T) {
head := &headState{
id: 1,
modeIDs: make([]uint32, 0),
}
head.modeIDs = append(head.modeIDs, 1, 2, 3)
assert.Len(t, head.modeIDs, 3)
assert.Equal(t, uint32(1), head.modeIDs[0])
}
func TestStateChanged_BothCurrentModeNil(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
assert.False(t, stateChanged(a, b))
}
func TestStateChanged_IndexOutOfBounds(t *testing.T) {
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}}
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}, {Name: "HDMI-A-1"}}}
assert.True(t, stateChanged(a, b))
}

View File

@@ -139,10 +139,32 @@ in
)}
if [ -f session.json ]; then
if cp "$(${jq} -r '.wallpaperPath' session.json)" wallpaper.jpg; then
mv session.json session.orig.json
${jq} '.wallpaperPath = "${cacheDir}/wallpaper.jpg"' session.orig.json > session.json
fi
copy_wallpaper() {
local path=$(${jq} -r ".$1 // empty" session.json)
if [ -f "$path" ]; then
cp "$path" "$2"
${jq} ".$1 = \"${cacheDir}/$2\"" session.json > session.tmp
mv session.tmp session.json
fi
}
copy_monitor_wallpapers() {
${jq} -r ".$1 // {} | to_entries[] | .key + \":\" + .value" session.json 2>/dev/null | while IFS=: read monitor path; do
local dest="$2-$(echo "$monitor" | tr -c '[:alnum:]' '-')"
if [ -f "$path" ]; then
cp "$path" "$dest"
${jq} --arg m "$monitor" --arg p "${cacheDir}/$dest" ".$1[\$m] = \$p" session.json > session.tmp
mv session.tmp session.json
fi
done
}
copy_wallpaper "wallpaperPath" "wallpaper"
copy_wallpaper "wallpaperPathLight" "wallpaper-light"
copy_wallpaper "wallpaperPathDark" "wallpaper-dark"
copy_monitor_wallpapers "monitorWallpapers" "wallpaper-monitor"
copy_monitor_wallpapers "monitorWallpapersLight" "wallpaper-monitor-light"
copy_monitor_wallpapers "monitorWallpapersDark" "wallpaper-monitor-dark"
fi
if [ -f settings.json ]; then

View File

@@ -203,10 +203,6 @@ Singleton {
"value": "scheme-vibrant",
"label": "Vibrant",
"description": I18n.tr("Lively palette with saturated accents.")
}), ({
"value": "scheme-dynamic-contrast",
"label": "Dynamic Contrast",
"description": I18n.tr("High-contrast palette for strong visual distinction.")
}), ({
"value": "scheme-content",
"label": "Content",

View File

@@ -14,7 +14,7 @@ DankModal {
layerNamespace: "dms:clipboard"
HyprlandFocusGrab {
windows: [clipboardHistoryModal.contentWindow]
windows: [clipboardHistoryModal.contentWindow, clipboardHistoryModal.backgroundWindow]
active: clipboardHistoryModal.useHyprlandFocusGrab && clipboardHistoryModal.shouldHaveFocus
}

View File

@@ -49,12 +49,14 @@ Item {
readonly property alias backgroundWindow: backgroundWindow
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property bool useSingleWindow: root.useHyprlandFocusGrab
signal opened
signal dialogClosed
signal backgroundClicked
property bool animationsEnabled: true
readonly property bool useBackgroundWindow: true
readonly property bool useBackgroundWindow: !useSingleWindow
function open() {
ModalManager.openModal(root);
@@ -205,7 +207,7 @@ Item {
MouseArea {
anchors.fill: parent
enabled: root.closeOnBackgroundClick && root.shouldBeVisible
enabled: root.useBackgroundWindow && root.closeOnBackgroundClick && root.shouldBeVisible
onClicked: mouse => {
const clickX = mouse.x;
const clickY = mouse.y;
@@ -222,7 +224,7 @@ Item {
anchors.fill: parent
color: "black"
opacity: root.showBackground && SettingsData.modalDarkenBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
visible: root.showBackground && SettingsData.modalDarkenBackground
visible: root.useBackgroundWindow && root.showBackground && SettingsData.modalDarkenBackground
Behavior on opacity {
enabled: root.animationsEnabled
@@ -271,15 +273,19 @@ Item {
anchors {
left: true
top: true
right: root.useSingleWindow
bottom: root.useSingleWindow
}
WlrLayershell.margins {
left: Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr))
top: Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr))
left: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr))
top: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr))
right: 0
bottom: 0
}
implicitWidth: root.alignedWidth + (shadowBuffer * 2)
implicitHeight: root.alignedHeight + (shadowBuffer * 2)
implicitWidth: root.useSingleWindow ? undefined : root.alignedWidth + (shadowBuffer * 2)
implicitHeight: root.useSingleWindow ? undefined : root.alignedHeight + (shadowBuffer * 2)
onVisibleChanged: {
if (visible) {
@@ -292,13 +298,48 @@ Item {
}
}
MouseArea {
anchors.fill: parent
enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible
z: -2
onClicked: root.backgroundClicked()
}
Rectangle {
anchors.fill: parent
z: -1
color: "black"
opacity: root.showBackground && SettingsData.modalDarkenBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
visible: root.useSingleWindow && root.showBackground && SettingsData.modalDarkenBackground
Behavior on opacity {
enabled: root.animationsEnabled
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
}
Item {
id: modalContainer
x: shadowBuffer
y: shadowBuffer
x: root.useSingleWindow ? root.alignedX : shadowBuffer
y: root.useSingleWindow ? root.alignedY : shadowBuffer
width: root.alignedWidth
height: root.alignedHeight
MouseArea {
anchors.fill: parent
enabled: root.useSingleWindow
hoverEnabled: false
acceptedButtons: Qt.AllButtons
onPressed: mouse.accepted = true
onClicked: mouse.accepted = true
z: -1
}
readonly property bool slide: root.animationType === "slide"
readonly property real offsetX: slide ? 15 : 0
readonly property real offsetY: slide ? -30 : root.animationOffset

View File

@@ -14,7 +14,7 @@ DankModal {
keepPopoutsOpen: true
HyprlandFocusGrab {
windows: [root.contentWindow]
windows: [root.contentWindow, root.backgroundWindow]
active: root.useHyprlandFocusGrab && root.shouldHaveFocus
}

View File

@@ -11,7 +11,7 @@ DankModal {
layerNamespace: "dms:spotlight"
HyprlandFocusGrab {
windows: [spotlightModal.contentWindow]
windows: [spotlightModal.contentWindow, spotlightModal.backgroundWindow]
active: spotlightModal.useHyprlandFocusGrab && spotlightModal.shouldHaveFocus
}

View File

@@ -79,10 +79,12 @@ Variants {
}
Component.onCompleted: {
if (source) {
const formattedSource = source.startsWith("file://") ? source : "file://" + source;
setWallpaperImmediate(formattedSource);
if (!source) {
isInitialized = true;
return;
}
const formattedSource = source.startsWith("file://") ? source : "file://" + source;
setWallpaperImmediate(formattedSource);
isInitialized = true;
}
@@ -93,22 +95,23 @@ Variants {
property bool useNextForEffect: false
onSourceChanged: {
const isColor = source.startsWith("#");
if (!source) {
if (!source || source.startsWith("#")) {
setWallpaperImmediate("");
} else if (isColor) {
setWallpaperImmediate("");
} else {
if (!isInitialized || !currentWallpaper.source) {
setWallpaperImmediate(source.startsWith("file://") ? source : "file://" + source);
isInitialized = true;
} else if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(source.startsWith("file://") ? source : "file://" + source);
} else {
changeWallpaper(source.startsWith("file://") ? source : "file://" + source);
}
return;
}
const formattedSource = source.startsWith("file://") ? source : "file://" + source;
if (!isInitialized || !currentWallpaper.source) {
setWallpaperImmediate(formattedSource);
isInitialized = true;
return;
}
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(formattedSource);
return;
}
changeWallpaper(formattedSource);
}
function setWallpaperImmediate(newSource) {
@@ -120,15 +123,18 @@ Variants {
}
function startTransition() {
currentWallpaper.cache = true;
nextWallpaper.cache = true;
root.useNextForEffect = true;
root.effectActive = true;
if (srcNext.scheduleUpdate)
srcNext.scheduleUpdate();
Qt.callLater(() => {
transitionAnimation.start();
});
transitionDelayTimer.start();
}
Timer {
id: transitionDelayTimer
interval: 16
repeat: false
onTriggered: transitionAnimation.start()
}
function changeWallpaper(newPath) {
@@ -143,7 +149,6 @@ Variants {
currentWallpaper.source = nextWallpaper.source;
nextWallpaper.source = "";
}
if (!currentWallpaper.source) {
setWallpaperImmediate(newPath);
return;
@@ -151,9 +156,8 @@ Variants {
nextWallpaper.source = newPath;
if (nextWallpaper.status === Image.Ready) {
if (nextWallpaper.status === Image.Ready)
root.startTransition();
}
}
Loader {
@@ -166,9 +170,9 @@ Variants {
}
}
property real screenScale: CompositorService.getScreenScale(modelData)
property int physicalWidth: Math.round(modelData.width * screenScale)
property int physicalHeight: Math.round(modelData.height * screenScale)
readonly property int maxTextureSize: 8192
property int textureWidth: Math.min(modelData.width, maxTextureSize)
property int textureHeight: Math.min(modelData.height, maxTextureSize)
Image {
id: currentWallpaper
@@ -178,7 +182,7 @@ Variants {
asynchronous: true
smooth: true
cache: true
sourceSize: Qt.size(root.physicalWidth, root.physicalHeight)
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SettingsData.wallpaperFillMode)
}
@@ -189,8 +193,8 @@ Variants {
opacity: 0
asynchronous: true
smooth: true
cache: false
sourceSize: Qt.size(root.physicalWidth, root.physicalHeight)
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SettingsData.wallpaperFillMode)
onStatusChanged: {
@@ -209,7 +213,7 @@ Variants {
live: root.effectActive
mipmap: false
recursive: false
textureSize: root.effectActive ? Qt.size(root.physicalWidth, root.physicalHeight) : Qt.size(1, 1)
textureSize: Qt.size(root.textureWidth, root.textureHeight)
}
Rectangle {
@@ -265,19 +269,12 @@ Variants {
duration: 1000
easing.type: Easing.InOutCubic
onFinished: {
if (nextWallpaper.source && nextWallpaper.status === Image.Ready) {
if (nextWallpaper.source && nextWallpaper.status === Image.Ready)
currentWallpaper.source = nextWallpaper.source;
}
root.useNextForEffect = false;
Qt.callLater(() => {
nextWallpaper.source = "";
Qt.callLater(() => {
root.effectActive = false;
currentWallpaper.cache = true;
nextWallpaper.cache = false;
root.transitionProgress = 0.0;
});
});
nextWallpaper.source = "";
root.transitionProgress = 0.0;
root.effectActive = false;
}
}
}

View File

@@ -27,7 +27,7 @@ PluginComponent {
ccDetailContent: Component {
VpnDetailContent {
listHeight: 180
listHeight: 260
}
}
}

View File

@@ -18,13 +18,16 @@ Item {
function getDetailHeight(section) {
const maxAvailable = parent ? parent.height - Theme.spacingS : 9999;
if (section === "wifi")
switch (true) {
case section === "wifi":
case section === "bluetooth":
case section === "builtin_vpn":
return Math.min(350, maxAvailable);
if (section === "bluetooth")
return Math.min(350, maxAvailable);
if (section.startsWith("brightnessSlider_"))
case section.startsWith("brightnessSlider_"):
return Math.min(400, maxAvailable);
return Math.min(250, maxAvailable);
default:
return Math.min(250, maxAvailable);
}
}
Loader {

View File

@@ -552,22 +552,31 @@ DankPopout {
}
}
DankButtonGroup {
property var profileModel: (typeof PowerProfiles !== "undefined") ? [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []) : [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
property int currentProfileIndex: {
if (typeof PowerProfiles === "undefined")
return 1;
return profileModel.findIndex(profile => root.isActiveProfile(profile));
}
Item {
width: parent.width
height: profileButtonGroup.height * profileButtonGroup.scale
model: profileModel.map(profile => Theme.getPowerProfileLabel(profile))
currentIndex: currentProfileIndex
selectionMode: "single"
anchors.horizontalCenter: parent.horizontalCenter
onSelectionChanged: (index, selected) => {
if (!selected)
return;
root.setProfile(profileModel[index]);
DankButtonGroup {
id: profileButtonGroup
property var profileModel: (typeof PowerProfiles !== "undefined") ? [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []) : [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
property int currentProfileIndex: {
if (typeof PowerProfiles === "undefined")
return 1;
return profileModel.findIndex(profile => root.isActiveProfile(profile));
}
scale: Math.min(1, parent.width / implicitWidth)
transformOrigin: Item.Center
anchors.horizontalCenter: parent.horizontalCenter
model: profileModel.map(profile => Theme.getPowerProfileLabel(profile))
currentIndex: currentProfileIndex
selectionMode: "single"
onSelectionChanged: (index, selected) => {
if (!selected)
return;
root.setProfile(profileModel[index]);
}
}
}

View File

@@ -15,6 +15,7 @@ DankPopout {
triggerY = y;
triggerWidth = width;
triggerSection = section;
triggerScreen = screen;
root.screen = screen;
storedBarThickness = barThickness !== undefined ? barThickness : (Theme.barHeight - 4);
@@ -102,6 +103,8 @@ DankPopout {
screen: triggerScreen
shouldBeVisible: false
onBackgroundClicked: close()
content: Component {
Rectangle {
id: layoutContent

View File

@@ -229,7 +229,7 @@ Item {
DankTextField {
id: searchField
width: parent.width - addButton.width - Theme.spacingM
height: 44
height: Math.round(Theme.fontSizeMedium * 3)
placeholderText: I18n.tr("Search keybinds...")
leftIconName: "search"
onTextChanged: {
@@ -240,8 +240,8 @@ Item {
DankActionButton {
id: addButton
width: 44
height: 44
width: searchField.height
height: searchField.height
circular: false
iconName: "add"
iconSize: Theme.iconSize
@@ -331,7 +331,7 @@ Item {
Rectangle {
id: fixButton
width: fixButtonText.implicitWidth + Theme.spacingL * 2
height: 36
height: Math.round(Theme.fontSizeMedium * 2.5)
radius: Theme.cornerRadius
visible: warningBox.showError || warningBox.showSetup
color: KeybindsService.fixing ? Theme.withAlpha(Theme.error, 0.6) : Theme.error
@@ -382,9 +382,10 @@ Item {
spacing: Theme.spacingS
Rectangle {
readonly property real chipHeight: allChip.implicitHeight + Theme.spacingM
width: allChip.implicitWidth + Theme.spacingL
height: 32
radius: 16
height: chipHeight
radius: chipHeight / 2
color: !keybindsTab.selectedCategory ? Theme.primary : Theme.surfaceContainerHighest
StyledText {
@@ -412,9 +413,10 @@ Item {
required property string modelData
required property int index
readonly property real chipHeight: catText.implicitHeight + Theme.spacingM
width: catText.implicitWidth + Theme.spacingL
height: 32
radius: 16
height: chipHeight
radius: chipHeight / 2
color: keybindsTab.selectedCategory === modelData ? Theme.primary : (modelData === "__overrides__" ? Theme.withAlpha(Theme.primary, 0.15) : Theme.surfaceContainerHighest)
StyledText {

View File

@@ -57,15 +57,11 @@ Variants {
}
}
onTransitionTypeChanged: {
if (transitionType === "random") {
if (SessionData.includedTransitions.length === 0) {
actualTransitionType = "none";
} else {
actualTransitionType = SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)];
}
} else {
if (transitionType !== "random") {
actualTransitionType = transitionType;
return;
}
actualTransitionType = SessionData.includedTransitions.length === 0 ? "none" : SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)];
}
property real transitionProgress: 0
@@ -108,30 +104,33 @@ Variants {
}
Component.onCompleted: {
if (source) {
const formattedSource = source.startsWith("file://") ? source : "file://" + source;
setWallpaperImmediate(formattedSource);
if (!source) {
isInitialized = true;
return;
}
const formattedSource = source.startsWith("file://") ? source : "file://" + source;
setWallpaperImmediate(formattedSource);
isInitialized = true;
}
onSourceChanged: {
const isColor = source.startsWith("#");
if (!source) {
if (!source || source.startsWith("#")) {
setWallpaperImmediate("");
} else if (isColor) {
setWallpaperImmediate("");
} else {
if (!isInitialized || !currentWallpaper.source) {
setWallpaperImmediate(source.startsWith("file://") ? source : "file://" + source);
isInitialized = true;
} else if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(source.startsWith("file://") ? source : "file://" + source);
} else {
changeWallpaper(source.startsWith("file://") ? source : "file://" + source);
}
return;
}
const formattedSource = source.startsWith("file://") ? source : "file://" + source;
if (!isInitialized || !currentWallpaper.source) {
setWallpaperImmediate(formattedSource);
isInitialized = true;
return;
}
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(formattedSource);
return;
}
changeWallpaper(formattedSource);
}
function setWallpaperImmediate(newSource) {
@@ -143,8 +142,6 @@ Variants {
}
function startTransition() {
currentWallpaper.cache = true;
nextWallpaper.cache = true;
currentWallpaper.layer.enabled = true;
nextWallpaper.layer.enabled = true;
root.useNextForEffect = true;
@@ -153,9 +150,14 @@ Variants {
srcCurrent.scheduleUpdate();
if (srcNext.scheduleUpdate)
srcNext.scheduleUpdate();
Qt.callLater(() => {
transitionAnimation.start();
});
transitionDelayTimer.start();
}
Timer {
id: transitionDelayTimer
interval: 16
repeat: false
onTriggered: transitionAnimation.start()
}
function changeWallpaper(newPath, force) {
@@ -163,23 +165,17 @@ Variants {
return;
if (!newPath || newPath.startsWith("#"))
return;
if (root.transitioning || root.effectActive) {
root.pendingWallpaper = newPath;
return;
}
if (!currentWallpaper.source) {
setWallpaperImmediate(newPath);
return;
}
if (root.transitionType === "random") {
if (SessionData.includedTransitions.length === 0) {
root.actualTransitionType = "none";
} else {
root.actualTransitionType = SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)];
}
root.actualTransitionType = SessionData.includedTransitions.length === 0 ? "none" : SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)];
}
if (root.actualTransitionType === "none") {
@@ -187,21 +183,26 @@ Variants {
return;
}
if (root.actualTransitionType === "wipe") {
switch (root.actualTransitionType) {
case "wipe":
root.wipeDirection = Math.random() * 4;
} else if (root.actualTransitionType === "disc" || root.actualTransitionType === "pixelate" || root.actualTransitionType === "portal") {
break;
case "disc":
case "pixelate":
case "portal":
root.discCenterX = Math.random();
root.discCenterY = Math.random();
} else if (root.actualTransitionType === "stripes") {
break;
case "stripes":
root.stripesCount = Math.round(Math.random() * 20 + 4);
root.stripesAngle = Math.random() * 360;
break;
}
nextWallpaper.source = newPath;
if (nextWallpaper.status === Image.Ready) {
if (nextWallpaper.status === Image.Ready)
root.startTransition();
}
}
Loader {
@@ -214,9 +215,10 @@ Variants {
}
}
readonly property int maxTextureSize: 8192
property real screenScale: CompositorService.getScreenScale(modelData)
property int physicalWidth: Math.round(modelData.width * screenScale)
property int physicalHeight: Math.round(modelData.height * screenScale)
property int textureWidth: Math.min(Math.round(modelData.width * screenScale), maxTextureSize)
property int textureHeight: Math.min(Math.round(modelData.height * screenScale), maxTextureSize)
Image {
id: currentWallpaper
@@ -227,7 +229,7 @@ Variants {
asynchronous: true
smooth: true
cache: true
sourceSize: Qt.size(root.physicalWidth, root.physicalHeight)
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SettingsData.wallpaperFillMode)
}
@@ -239,8 +241,8 @@ Variants {
layer.enabled: false
asynchronous: true
smooth: true
cache: false
sourceSize: Qt.size(root.physicalWidth, root.physicalHeight)
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SettingsData.wallpaperFillMode)
onStatusChanged: {
@@ -263,7 +265,7 @@ Variants {
live: root.effectActive
mipmap: false
recursive: false
textureSize: root.effectActive ? Qt.size(root.physicalWidth, root.physicalHeight) : Qt.size(1, 1)
textureSize: Qt.size(root.textureWidth, root.textureHeight)
}
ShaderEffectSource {
@@ -273,7 +275,7 @@ Variants {
live: root.effectActive
mipmap: false
recursive: false
textureSize: root.effectActive ? Qt.size(root.physicalWidth, root.physicalHeight) : Qt.size(1, 1)
textureSize: Qt.size(root.textureWidth, root.textureHeight)
}
Rectangle {
@@ -297,8 +299,9 @@ Variants {
id: effectLoader
anchors.fill: parent
active: root.effectActive
sourceComponent: {
switch (root.actualTransitionType) {
function getTransitionComponent(type) {
switch (type) {
case "fade":
return fadeComp;
case "wipe":
@@ -317,6 +320,8 @@ Variants {
return null;
}
}
sourceComponent: getTransitionComponent(root.actualTransitionType)
}
Component {
@@ -491,17 +496,13 @@ Variants {
root.transitionProgress = 0.0;
currentWallpaper.layer.enabled = false;
nextWallpaper.layer.enabled = false;
currentWallpaper.cache = true;
nextWallpaper.cache = false;
root.effectActive = false;
if (root.pendingWallpaper) {
var pending = root.pendingWallpaper;
root.pendingWallpaper = "";
Qt.callLater(() => {
root.changeWallpaper(pending, true);
});
}
if (!root.pendingWallpaper)
return;
var pending = root.pendingWallpaper;
root.pendingWallpaper = "";
Qt.callLater(() => root.changeWallpaper(pending, true));
}
}

View File

@@ -30,7 +30,7 @@ Singleton {
id: cavaProcess
running: root.cavaAvailable && root.refCount > 0
command: ["sh", "-c", "printf '[general]\\nframerate=25\\nbars=6\\nautosens=0\\nsensitivity=30\\nlower_cutoff_freq=50\\nhigher_cutoff_freq=12000\\n[input]\\nmethod=pipewire\\nsource=auto\\nsample_rate=48000\\n[output]\\nmethod=raw\\nraw_target=/dev/stdout\\ndata_format=ascii\\nchannels=mono\\nmono_option=average\\n[smoothing]\\nnoise_reduction=35\\nintegral=90\\ngravity=95\\nignore=2\\nmonstercat=1.5' | cava -p /dev/stdin"]
command: ["sh", "-c", "printf '[general]\\nframerate=25\\nbars=6\\nautosens=0\\nsensitivity=30\\nlower_cutoff_freq=50\\nhigher_cutoff_freq=12000\\n[input]\\nsample_rate=48000\\n[output]\\nmethod=raw\\nraw_target=/dev/stdout\\ndata_format=ascii\\nchannels=mono\\nmono_option=average\\n[smoothing]\\nnoise_reduction=35\\nintegral=90\\ngravity=95\\nignore=2\\nmonstercat=1.5' | cava -p /dev/stdin"]
onRunningChanged: {
if (!running) {

View File

@@ -66,6 +66,19 @@ Singleton {
onTriggered: root.doGenerateNiriLayoutConfig()
}
property int _lastGapValue: -1
Connections {
target: SettingsData
function onBarConfigsChanged() {
const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4));
if (newGaps === root._lastGapValue)
return;
root._lastGapValue = newGaps;
generateNiriLayoutConfig();
}
}
Process {
id: validateProcess
command: ["niri", "validate"]

View File

@@ -1 +1 @@
v1.0.2
v1.0.3

View File

@@ -1,5 +1,4 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Widgets
@@ -13,7 +12,7 @@ StyledRect {
onActiveFocusChanged: {
if (activeFocus) {
textInput.forceActiveFocus()
textInput.forceActiveFocus();
}
}
@@ -53,26 +52,26 @@ StyledRect {
signal focusStateChanged(bool hasFocus)
function getActiveFocus() {
return textInput.activeFocus
return textInput.activeFocus;
}
function setFocus(value) {
textInput.focus = value
textInput.focus = value;
}
function forceActiveFocus() {
textInput.forceActiveFocus()
textInput.forceActiveFocus();
}
function selectAll() {
textInput.selectAll()
textInput.selectAll();
}
function clear() {
textInput.clear()
textInput.clear();
}
function insertText(str) {
textInput.insert(textInput.cursorPosition, str)
textInput.insert(textInput.cursorPosition, str);
}
width: 200
height: 48
height: Math.round(Theme.fontSizeMedium * 3.4)
radius: cornerRadius
color: backgroundColor
border.color: textInput.activeFocus ? focusedBorderColor : normalBorderColor
@@ -112,30 +111,30 @@ StyledRect {
onActiveFocusChanged: root.focusStateChanged(activeFocus)
Keys.forwardTo: root.keyForwardTargets
Keys.onLeftPressed: event => {
if (root.ignoreLeftRightKeys) {
event.accepted = true
} else {
// Allow normal TextInput cursor movement
event.accepted = false
}
}
if (root.ignoreLeftRightKeys) {
event.accepted = true;
} else {
// Allow normal TextInput cursor movement
event.accepted = false;
}
}
Keys.onRightPressed: event => {
if (root.ignoreLeftRightKeys) {
event.accepted = true
} else {
event.accepted = false
}
}
if (root.ignoreLeftRightKeys) {
event.accepted = true;
} else {
event.accepted = false;
}
}
Keys.onPressed: event => {
if (root.ignoreTabKeys && (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab)) {
event.accepted = false
for (var i = 0; i < root.keyForwardTargets.length; i++) {
if (root.keyForwardTargets[i]) {
root.keyForwardTargets[i].Keys.pressed(event)
}
}
}
}
if (root.ignoreTabKeys && (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab)) {
event.accepted = false;
for (var i = 0; i < root.keyForwardTargets.length; i++) {
if (root.keyForwardTargets[i]) {
root.keyForwardTargets[i].Keys.pressed(event);
}
}
}
}
MouseArea {
anchors.fill: parent
@@ -171,7 +170,7 @@ StyledRect {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
textInput.text = ""
textInput.text = "";
}
}
}

View File

@@ -54,6 +54,12 @@ Item {
readonly property var _conflicts: editKey ? KeyUtils.getConflictingBinds(editKey, bindData.action, KeybindsService.getFlatBinds()) : []
readonly property bool hasConflict: _conflicts.length > 0
readonly property real _inputHeight: Math.round(Theme.fontSizeMedium * 3)
readonly property real _chipHeight: Math.round(Theme.fontSizeSmall * 2.3)
readonly property real _buttonHeight: Math.round(Theme.fontSizeMedium * 2.3)
readonly property real _keysColumnWidth: Math.round(Theme.fontSizeSmall * 12)
readonly property real _labelWidth: Math.round(Theme.fontSizeSmall * 5)
signal toggleExpand
signal saveBind(string originalKey, var newData)
signal removeBind(string key)
@@ -223,7 +229,7 @@ Item {
Rectangle {
id: collapsedRect
width: parent.width
height: Math.max(52, keysColumn.implicitHeight + Theme.spacingM * 2)
height: Math.max(root._inputHeight + Theme.spacingM, keysColumn.implicitHeight + Theme.spacingM * 2)
radius: root.isExpanded ? 0 : Theme.cornerRadius
topLeftRadius: Theme.cornerRadius
topRightRadius: Theme.cornerRadius
@@ -240,7 +246,7 @@ Item {
Column {
id: keysColumn
Layout.preferredWidth: 140
Layout.preferredWidth: root._keysColumnWidth
Layout.alignment: Qt.AlignVCenter
spacing: Theme.spacingXS
@@ -253,9 +259,9 @@ Item {
property bool isSelected: root.isExpanded && root.editingKeyIndex === index && !root.addingNewKey
width: 140
height: 28
radius: 6
width: root._keysColumnWidth
height: root._chipHeight
radius: root._chipHeight / 4
color: isSelected ? Theme.primary : Theme.surfaceVariant
Rectangle {
@@ -332,7 +338,7 @@ Item {
DankIcon {
name: "warning"
size: 14
size: Theme.iconSizeSmall
color: Theme.primary
visible: root.hasConfigConflict
}
@@ -352,7 +358,7 @@ Item {
DankIcon {
name: root.isExpanded ? "expand_less" : "expand_more"
size: 20
size: Theme.iconSize - 4
color: Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter
}
@@ -360,7 +366,7 @@ Item {
MouseArea {
anchors.fill: parent
anchors.leftMargin: 140 + Theme.spacingM * 2
anchors.leftMargin: root._keysColumnWidth + Theme.spacingM * 2
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleExpand()
}
@@ -420,7 +426,7 @@ Item {
DankIcon {
name: "warning"
size: 16
size: Theme.iconSizeSmall
color: Theme.primary
}
@@ -461,7 +467,7 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
Flow {
@@ -478,8 +484,8 @@ Item {
property bool isSelected: root.editingKeyIndex === index && !root.addingNewKey
width: editKeyChipText.implicitWidth + Theme.spacingM
height: 28
radius: 6
height: root._chipHeight
radius: root._chipHeight / 4
color: isSelected ? Theme.primary : Theme.surfaceVariant
Rectangle {
@@ -509,9 +515,9 @@ Item {
}
Rectangle {
width: 28
height: 28
radius: 6
width: root._chipHeight
height: root._chipHeight
radius: root._chipHeight / 4
color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant
visible: !root.isNew
@@ -523,7 +529,7 @@ Item {
DankIcon {
name: "add"
size: 16
size: Theme.iconSizeSmall
color: root.addingNewKey ? Theme.primaryText : Theme.surfaceVariantText
anchors.centerIn: parent
}
@@ -548,13 +554,13 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
FocusScope {
id: captureScope
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredHeight: root._inputHeight
focus: root.recording
Component.onCompleted: {
@@ -596,12 +602,12 @@ Item {
DankActionButton {
id: recordBtn
width: 28
height: 28
width: root._chipHeight
height: root._chipHeight
anchors.verticalCenter: parent.verticalCenter
circular: false
iconName: root.recording ? "close" : "radio_button_checked"
iconSize: 16
iconSize: Theme.iconSizeSmall
iconColor: root.recording ? Theme.error : Theme.primary
onClicked: root.recording ? root.stopRecording() : root.startRecording()
}
@@ -703,8 +709,8 @@ Item {
}
Rectangle {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
Layout.preferredWidth: root._inputHeight
Layout.preferredHeight: root._inputHeight
radius: Theme.cornerRadius
color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant
visible: root.keys.length === 1 && !root.isNew
@@ -717,7 +723,7 @@ Item {
DankIcon {
name: "add"
size: 18
size: Theme.iconSizeSmall + 2
color: root.addingNewKey ? Theme.primaryText : Theme.surfaceVariantText
anchors.centerIn: parent
}
@@ -736,11 +742,11 @@ Item {
Layout.fillWidth: true
spacing: Theme.spacingS
visible: root.hasConflict
Layout.leftMargin: 60 + Theme.spacingM
Layout.leftMargin: root._labelWidth + Theme.spacingM
DankIcon {
name: "warning"
size: 16
size: Theme.iconSizeSmall
color: Theme.primary
}
@@ -762,7 +768,7 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
RowLayout {
@@ -785,7 +791,7 @@ Item {
})
Layout.fillWidth: true
Layout.preferredHeight: 36
Layout.preferredHeight: root._buttonHeight
radius: Theme.cornerRadius
color: root._actionType === modelData.id ? Theme.surfaceContainerHighest : Theme.surfaceContainer
border.color: root._actionType === modelData.id ? Theme.outline : (typeArea.containsMouse ? Theme.outlineVariant : "transparent")
@@ -797,7 +803,7 @@ Item {
DankIcon {
name: typeDelegate.modelData.icon
size: 16
size: Theme.iconSizeSmall
color: root._actionType === typeDelegate.modelData.id ? Theme.surfaceText : Theme.surfaceVariantText
}
@@ -869,7 +875,7 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
DankDropdown {
@@ -913,14 +919,14 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
visible: dmsArgsRow.hasAmountArg
}
DankTextField {
id: dmsAmountField
Layout.preferredWidth: 80
Layout.preferredHeight: 40
Layout.preferredWidth: Math.round(Theme.fontSizeMedium * 5.5)
Layout.preferredHeight: root._inputHeight
placeholderText: "5"
visible: dmsArgsRow.hasAmountArg
@@ -961,14 +967,14 @@ Item {
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.leftMargin: dmsArgsRow.hasAmountArg ? Theme.spacingM : 0
Layout.preferredWidth: dmsArgsRow.hasAmountArg ? -1 : 60
Layout.preferredWidth: dmsArgsRow.hasAmountArg ? -1 : root._labelWidth
visible: dmsArgsRow.hasDeviceArg
}
DankTextField {
id: dmsDeviceField
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredHeight: root._inputHeight
placeholderText: I18n.tr("leave empty for default")
visible: dmsArgsRow.hasDeviceArg
@@ -1006,7 +1012,7 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
visible: dmsArgsRow.hasTabArg
}
@@ -1064,12 +1070,12 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
DankDropdown {
id: compositorCatDropdown
Layout.preferredWidth: 120
Layout.preferredWidth: Math.round(Theme.fontSizeMedium * 8.5)
compactMode: true
currentValue: {
const base = root.editAction.split(" ")[0];
@@ -1108,8 +1114,8 @@ Item {
}
Rectangle {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
Layout.preferredWidth: root._inputHeight
Layout.preferredHeight: root._inputHeight
radius: Theme.cornerRadius
color: Theme.surfaceVariant
@@ -1121,7 +1127,7 @@ Item {
DankIcon {
name: "edit"
size: 18
size: Theme.iconSizeSmall + 2
color: Theme.surfaceVariantText
anchors.centerIn: parent
}
@@ -1150,7 +1156,7 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
RowLayout {
@@ -1160,7 +1166,7 @@ Item {
DankTextField {
id: argValueField
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredHeight: root._inputHeight
visible: {
const cfg = optionsRow.argConfig;
if (!cfg?.config?.args)
@@ -1308,13 +1314,13 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
DankTextField {
id: customCompositorField
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredHeight: root._inputHeight
placeholderText: I18n.tr("e.g., focus-workspace 3, resize-column -10")
text: root._actionType === "compositor" ? root.editAction : ""
onTextChanged: {
@@ -1327,8 +1333,8 @@ Item {
}
Rectangle {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
Layout.preferredWidth: root._inputHeight
Layout.preferredHeight: root._inputHeight
radius: Theme.cornerRadius
color: Theme.surfaceVariant
@@ -1340,7 +1346,7 @@ Item {
DankIcon {
name: "list"
size: 18
size: Theme.iconSizeSmall + 2
color: Theme.surfaceVariantText
anchors.centerIn: parent
}
@@ -1371,13 +1377,13 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
DankTextField {
id: spawnTextField
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredHeight: root._inputHeight
placeholderText: I18n.tr("e.g., firefox, kitty --title foo")
readonly property var _parsed: root._actionType === "spawn" ? Actions.parseSpawnCommand(root.editAction) : null
text: _parsed ? (_parsed.command + " " + _parsed.args.join(" ")).trim() : ""
@@ -1403,13 +1409,13 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
DankTextField {
id: shellTextField
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredHeight: root._inputHeight
placeholderText: I18n.tr("e.g., notify-send 'Hello' && sleep 1")
text: root._actionType === "shell" ? Actions.parseShellCommand(root.editAction) : ""
onTextChanged: {
@@ -1431,13 +1437,13 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
DankTextField {
id: titleField
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredHeight: root._inputHeight
placeholderText: I18n.tr("Hotkey overlay title (optional)")
text: root.editDesc
onTextChanged: root.updateEdit({
@@ -1455,13 +1461,13 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
Layout.preferredWidth: 60
Layout.preferredWidth: root._labelWidth
}
DankTextField {
id: cooldownField
Layout.preferredWidth: 100
Layout.preferredHeight: 40
Layout.preferredWidth: Math.round(Theme.fontSizeMedium * 7)
Layout.preferredHeight: root._inputHeight
placeholderText: "0"
Connections {
@@ -1508,8 +1514,8 @@ Item {
spacing: Theme.spacingM
DankActionButton {
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.preferredWidth: root._buttonHeight
Layout.preferredHeight: root._buttonHeight
circular: false
iconName: "delete"
iconSize: Theme.iconSize - 4
@@ -1531,7 +1537,7 @@ Item {
DankButton {
text: I18n.tr("Cancel")
buttonHeight: 32
buttonHeight: root._buttonHeight
backgroundColor: Theme.surfaceContainer
textColor: Theme.surfaceText
visible: root.hasChanges || root.isNew
@@ -1547,7 +1553,7 @@ Item {
DankButton {
text: root.isNew ? I18n.tr("Add") : I18n.tr("Save")
buttonHeight: 32
buttonHeight: root._buttonHeight
enabled: root.canSave()
visible: root.hasChanges || root.isNew
onClicked: root.doSave()

View File

@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Common
import qs.Modals.Common
import qs.Modals.FileBrowser
@@ -13,7 +14,7 @@ Rectangle {
property string expandedUuid: ""
property int listHeight: 180
implicitHeight: contentColumn.implicitHeight + Theme.spacingM * 2
implicitHeight: 32 + 1 + listHeight + Theme.spacingS * 4 + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
@@ -153,328 +154,71 @@ Rectangle {
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
}
DankFlickable {
Item {
width: parent.width
height: root.listHeight
contentHeight: listCol.height
clip: true
Column {
id: listCol
width: parent.width
spacing: 4
anchors.centerIn: parent
spacing: Theme.spacingS
visible: DMSNetworkService.profiles.length === 0
Item {
width: parent.width
height: DMSNetworkService.profiles.length === 0 ? 100 : 0
visible: height > 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "vpn_key_off"
size: 36
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("No VPN profiles")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("Click Import to add a .ovpn or .conf")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
DankIcon {
name: "vpn_key_off"
size: 36
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
Repeater {
model: DMSNetworkService.profiles
StyledText {
text: I18n.tr("No VPN profiles")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
delegate: Rectangle {
id: profileRow
required property var modelData
required property int index
StyledText {
text: I18n.tr("Click Import to add a .ovpn or .conf")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
readonly property bool isActive: DMSNetworkService.isActiveUuid(modelData.uuid)
readonly property bool isExpanded: root.expandedUuid === modelData.uuid
readonly property bool isHovered: rowArea.containsMouse || expandBtn.containsMouse || deleteBtn.containsMouse
readonly property var configData: isExpanded ? VPNService.editConfig : null
DankListView {
id: vpnListView
anchors.fill: parent
visible: DMSNetworkService.profiles.length > 0
spacing: 4
cacheBuffer: 200
clip: true
width: listCol.width
height: isExpanded ? 46 + expandedContent.height : 46
radius: Theme.cornerRadius
color: isHovered ? Theme.primaryHoverLight : (isActive ? Theme.primaryPressed : Theme.surfaceLight)
border.width: isActive ? 2 : 1
border.color: isActive ? Theme.primary : Theme.outlineLight
opacity: DMSNetworkService.isBusy ? 0.5 : 1.0
clip: true
model: ScriptModel {
values: DMSNetworkService.profiles
objectProp: "uuid"
}
Behavior on height {
NumberAnimation {
duration: 150
easing.type: Easing.OutQuad
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: DMSNetworkService.isBusy ? Qt.BusyCursor : Qt.PointingHandCursor
enabled: !DMSNetworkService.isBusy
onClicked: DMSNetworkService.toggle(modelData.uuid)
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: Theme.spacingS
Row {
width: parent.width
height: 46 - Theme.spacingS * 2
spacing: Theme.spacingS
DankIcon {
name: isActive ? "vpn_lock" : "vpn_key_off"
size: 20
color: isActive ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: 1
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 20 - 28 - 28 - Theme.spacingS * 4
StyledText {
text: modelData.name
font.pixelSize: Theme.fontSizeMedium
color: isActive ? Theme.primary : Theme.surfaceText
elide: Text.ElideRight
wrapMode: Text.NoWrap
width: parent.width
}
StyledText {
text: VPNService.getVpnTypeFromProfile(modelData)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
wrapMode: Text.NoWrap
width: parent.width
elide: Text.ElideRight
}
}
Item {
width: Theme.spacingXS
height: 1
}
Rectangle {
id: expandBtnRect
width: 28
height: 28
radius: 14
color: expandBtn.containsMouse ? Theme.surfacePressed : "transparent"
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: isExpanded ? "expand_less" : "expand_more"
size: 18
color: Theme.surfaceText
}
MouseArea {
id: expandBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (isExpanded) {
root.expandedUuid = "";
} else {
root.expandedUuid = modelData.uuid;
VPNService.getConfig(modelData.uuid);
}
}
}
}
Rectangle {
id: deleteBtnRect
width: 28
height: 28
radius: 14
color: deleteBtn.containsMouse ? Theme.errorHover : "transparent"
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: "delete"
size: 18
color: deleteBtn.containsMouse ? Theme.error : Theme.surfaceVariantText
}
MouseArea {
id: deleteBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
deleteConfirm.showWithOptions({
title: I18n.tr("Delete VPN"),
message: I18n.tr("Delete \"") + modelData.name + "\"?",
confirmText: I18n.tr("Delete"),
confirmColor: Theme.error,
onConfirm: () => VPNService.deleteVpn(modelData.uuid)
});
}
}
}
}
Column {
id: expandedContent
width: parent.width
spacing: Theme.spacingXS
visible: isExpanded
Rectangle {
width: parent.width
height: 1
color: Theme.outlineLight
}
Item {
width: parent.width
height: VPNService.configLoading ? 40 : 0
visible: VPNService.configLoading
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "sync"
size: 16
color: Theme.surfaceVariantText
}
StyledText {
text: I18n.tr("Loading...")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
Flow {
width: parent.width
spacing: Theme.spacingXS
visible: !VPNService.configLoading && configData
Repeater {
model: {
if (!configData)
return [];
const fields = [];
const data = configData.data || {};
if (data.remote)
fields.push({
label: I18n.tr("Server"),
value: data.remote
});
if (configData.username || data.username)
fields.push({
label: I18n.tr("Username"),
value: configData.username || data.username
});
if (data.cipher)
fields.push({
label: I18n.tr("Cipher"),
value: data.cipher
});
if (data.auth)
fields.push({
label: I18n.tr("Auth"),
value: data.auth
});
if (data["proto-tcp"] === "yes" || data["proto-tcp"] === "no")
fields.push({
label: I18n.tr("Protocol"),
value: data["proto-tcp"] === "yes" ? "TCP" : "UDP"
});
if (data["tunnel-mtu"])
fields.push({
label: I18n.tr("MTU"),
value: data["tunnel-mtu"]
});
if (data["connection-type"])
fields.push({
label: I18n.tr("Auth Type"),
value: data["connection-type"]
});
fields.push({
label: I18n.tr("Autoconnect"),
value: configData.autoconnect ? I18n.tr("Yes") : I18n.tr("No")
});
return fields;
}
delegate: Rectangle {
required property var modelData
required property int index
width: fieldContent.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius - 2
color: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineLight
Row {
id: fieldContent
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: modelData.label + ":"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData.value
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Item {
width: 1
height: Theme.spacingXS
}
}
delegate: VpnProfileDelegate {
required property var modelData
width: vpnListView.width
profile: modelData
isExpanded: root.expandedUuid === modelData.uuid
onToggleExpand: {
if (root.expandedUuid === modelData.uuid) {
root.expandedUuid = "";
return;
}
root.expandedUuid = modelData.uuid;
VPNService.getConfig(modelData.uuid);
}
onDeleteRequested: {
deleteConfirm.showWithOptions({
"title": I18n.tr("Delete VPN"),
"message": I18n.tr("Delete \"") + modelData.name + "\"?",
"confirmText": I18n.tr("Delete"),
"confirmColor": Theme.error,
"onConfirm": () => VPNService.deleteVpn(modelData.uuid)
});
}
}
}

View File

@@ -0,0 +1,275 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
required property var profile
property bool isExpanded: false
signal toggleExpand
signal deleteRequested
readonly property bool isActive: DMSNetworkService.activeUuids?.includes(profile?.uuid) ?? false
readonly property bool isHovered: rowArea.containsMouse || expandBtn.containsMouse || deleteBtn.containsMouse
readonly property var configData: isExpanded ? VPNService.editConfig : null
readonly property var configFields: buildConfigFields()
height: isExpanded ? 46 + expandedContent.height : 46
radius: Theme.cornerRadius
color: isHovered ? Theme.primaryHoverLight : (isActive ? Theme.primaryPressed : Theme.surfaceLight)
border.width: isActive ? 2 : 1
border.color: isActive ? Theme.primary : Theme.outlineLight
opacity: DMSNetworkService.isBusy ? 0.5 : 1.0
clip: true
function buildConfigFields() {
if (!configData)
return [];
const fields = [];
const data = configData.data || {};
if (data.remote)
fields.push({
"key": "server",
"label": I18n.tr("Server"),
"value": data.remote
});
if (configData.username || data.username)
fields.push({
"key": "user",
"label": I18n.tr("Username"),
"value": configData.username || data.username
});
if (data.cipher)
fields.push({
"key": "cipher",
"label": I18n.tr("Cipher"),
"value": data.cipher
});
if (data.auth)
fields.push({
"key": "auth",
"label": I18n.tr("Auth"),
"value": data.auth
});
if (data["proto-tcp"] === "yes" || data["proto-tcp"] === "no")
fields.push({
"key": "proto",
"label": I18n.tr("Protocol"),
"value": data["proto-tcp"] === "yes" ? "TCP" : "UDP"
});
if (data["tunnel-mtu"])
fields.push({
"key": "mtu",
"label": I18n.tr("MTU"),
"value": data["tunnel-mtu"]
});
if (data["connection-type"])
fields.push({
"key": "conntype",
"label": I18n.tr("Auth Type"),
"value": data["connection-type"]
});
fields.push({
"key": "auto",
"label": I18n.tr("Autoconnect"),
"value": configData.autoconnect ? I18n.tr("Yes") : I18n.tr("No")
});
return fields;
}
Behavior on height {
NumberAnimation {
duration: 150
easing.type: Easing.OutQuad
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: DMSNetworkService.isBusy ? Qt.BusyCursor : Qt.PointingHandCursor
enabled: !DMSNetworkService.isBusy
onClicked: DMSNetworkService.toggle(profile.uuid)
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: Theme.spacingS
Row {
width: parent.width
height: 46 - Theme.spacingS * 2
spacing: Theme.spacingS
DankIcon {
name: isActive ? "vpn_lock" : "vpn_key_off"
size: 20
color: isActive ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: 1
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 20 - 28 - 28 - Theme.spacingS * 4
StyledText {
text: profile?.name ?? ""
font.pixelSize: Theme.fontSizeMedium
color: isActive ? Theme.primary : Theme.surfaceText
elide: Text.ElideRight
wrapMode: Text.NoWrap
width: parent.width
}
StyledText {
text: VPNService.getVpnTypeFromProfile(profile)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
wrapMode: Text.NoWrap
width: parent.width
elide: Text.ElideRight
}
}
Item {
width: Theme.spacingXS
height: 1
}
Rectangle {
width: 28
height: 28
radius: 14
color: expandBtn.containsMouse ? Theme.surfacePressed : "transparent"
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: isExpanded ? "expand_less" : "expand_more"
size: 18
color: Theme.surfaceText
}
MouseArea {
id: expandBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleExpand()
}
}
Rectangle {
width: 28
height: 28
radius: 14
color: deleteBtn.containsMouse ? Theme.errorHover : "transparent"
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: "delete"
size: 18
color: deleteBtn.containsMouse ? Theme.error : Theme.surfaceVariantText
}
MouseArea {
id: deleteBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.deleteRequested()
}
}
}
Column {
id: expandedContent
width: parent.width
spacing: Theme.spacingXS
visible: isExpanded
Rectangle {
width: parent.width
height: 1
color: Theme.outlineLight
}
Item {
width: parent.width
height: VPNService.configLoading ? 40 : 0
visible: VPNService.configLoading
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "sync"
size: 16
color: Theme.surfaceVariantText
}
StyledText {
text: I18n.tr("Loading...")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
Flow {
width: parent.width
spacing: Theme.spacingXS
visible: !VPNService.configLoading && configData
Repeater {
model: configFields
delegate: Rectangle {
required property var modelData
width: fieldContent.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius - 2
color: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineLight
Row {
id: fieldContent
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: modelData.label + ":"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData.value
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Item {
width: 1
height: Theme.spacingXS
}
}
}
}