Skip to content

Commit 04b0ad6

Browse files
authored
fix(matchers): match to the last matching profile deterministically (#16)
## What does this PR do? Match to the last profile deterministically. ## Related issues <!-- Closes #234 -->
2 parents 253e735 + 22e7f93 commit 04b0ad6

File tree

5 files changed

+117
-2
lines changed

5 files changed

+117
-2
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ monitor_tag = "laptop" # Assign a tag for template use
361361
Use `hyprctl monitors` to see available monitors and their properties.
362362
To understand scoring and profile matching more see [`examples/scoring`](https://github.com/fiffeek/hyprdynamicmonitors/tree/main/examples/scoring).
363363

364+
**Profile Selection**: When multiple profiles have equal scores, the last profile defined in the TOML configuration file is selected.
365+
364366
### Configuration File Types
365367

366368
- **Static**: Creates symlinks to existing configuration files

internal/config/config.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"slices"
910
"strings"
1011
"sync"
1112
"time"
@@ -64,6 +65,7 @@ type RawConfig struct {
6465
HotReload *HotReloadSection `toml:"hot_reload_section"`
6566
Notifications *Notifications `toml:"notifications"`
6667
StaticTemplateValues map[string]string `toml:"static_template_values"`
68+
KeysOrder []string `toml:"-"`
6769
}
6870

6971
type HotReloadSection struct {
@@ -172,6 +174,7 @@ type Profile struct {
172174
IsFallbackProfile bool `toml:"-"`
173175
PostApplyExec *string `toml:"post_apply_exec"`
174176
PreApplyExec *string `toml:"pre_apply_exec"`
177+
KeyOrder int `toml:"-"`
175178
}
176179

177180
type PowerStateType int
@@ -246,12 +249,18 @@ func Load(configPath string) (*RawConfig, error) {
246249
logrus.Debugf("Config contents: %s", contents)
247250

248251
var config RawConfig
249-
if _, err := toml.DecodeFile(configPath, &config); err != nil {
252+
m, err := toml.DecodeFile(configPath, &config)
253+
if err != nil {
250254
return nil, fmt.Errorf("failed to decode TOML: %w", err)
251255
}
256+
keys := []string{}
257+
for _, k := range m.Keys() {
258+
keys = append(keys, strings.Join(k, "."))
259+
}
252260

253261
config.ConfigPath = absConfig
254262
config.ConfigDirPath = filepath.Dir(config.ConfigPath)
263+
config.KeysOrder = keys
255264

256265
if err := config.Validate(); err != nil {
257266
return nil, fmt.Errorf("invalid configuration: %w", err)
@@ -262,6 +271,22 @@ func Load(configPath string) (*RawConfig, error) {
262271
return &config, nil
263272
}
264273

274+
// OrderedProfileKeys returns the profile names in the order they appear in the toml file
275+
func (c *RawConfig) OrderedProfileKeys() []string {
276+
profileNames := make([]string, 0, len(c.Profiles))
277+
for name := range c.Profiles {
278+
profileNames = append(profileNames, name)
279+
}
280+
281+
slices.SortFunc(profileNames, func(a, b string) int {
282+
orderA := c.Profiles[a].KeyOrder
283+
orderB := c.Profiles[b].KeyOrder
284+
return orderA - orderB
285+
})
286+
287+
return profileNames
288+
}
289+
265290
func (c *RawConfig) Validate() error {
266291
if c.ConfigPath == "" {
267292
return errors.New("config path cant be empty")
@@ -292,6 +317,8 @@ func (c *RawConfig) Validate() error {
292317
for name, profile := range c.Profiles {
293318
profile.Name = name
294319
profile.IsFallbackProfile = false
320+
profile.KeyOrder = slices.Index(c.KeysOrder, "profiles."+name)
321+
logrus.Debugf("Profile %s has order %d", profile.Name, profile.KeyOrder)
295322
if err := profile.Validate(c.ConfigDirPath); err != nil {
296323
return fmt.Errorf("profile %s validation failed: %w", name, err)
297324
}

internal/config/config_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,59 @@ func TestPowerSectionDbusQueryObjectDefaults(t *testing.T) {
984984
assert.Equal(t, expectedCollectedArgs, collectedArgs, "collected args should match expected")
985985
}
986986

987+
func TestOrderedProfileKeys(t *testing.T) {
988+
tests := []struct {
989+
name string
990+
profiles map[string]*config.Profile
991+
expected []string
992+
}{
993+
{
994+
name: "empty profiles",
995+
profiles: map[string]*config.Profile{},
996+
expected: []string{},
997+
},
998+
{
999+
name: "sorted by key order",
1000+
profiles: map[string]*config.Profile{
1001+
"third": {Name: "third", KeyOrder: 2},
1002+
"first": {Name: "first", KeyOrder: 0},
1003+
"second": {Name: "second", KeyOrder: 1},
1004+
},
1005+
expected: []string{"first", "second", "third"},
1006+
},
1007+
{
1008+
name: "negative key order comes first",
1009+
profiles: map[string]*config.Profile{
1010+
"missing": {Name: "missing", KeyOrder: -1},
1011+
"first": {Name: "first", KeyOrder: 0},
1012+
},
1013+
expected: []string{"missing", "first"},
1014+
},
1015+
}
1016+
1017+
for _, tt := range tests {
1018+
t.Run(tt.name, func(t *testing.T) {
1019+
config := &config.RawConfig{Profiles: tt.profiles}
1020+
result := config.OrderedProfileKeys()
1021+
1022+
assert.Equal(t, tt.expected, result)
1023+
})
1024+
}
1025+
1026+
// Test with actual TOML file
1027+
t.Run("order from TOML file", func(t *testing.T) {
1028+
cfg, err := config.Load(filepath.Join("testdata", "valid_basic.toml"))
1029+
if err != nil {
1030+
t.Fatalf("failed to load test config: %v", err)
1031+
}
1032+
1033+
result := cfg.OrderedProfileKeys()
1034+
expected := []string{"laptop_only", "dual_monitor", "ac_power_profile"}
1035+
1036+
assert.Equal(t, expected, result)
1037+
})
1038+
}
1039+
9871040
func containsString(haystack, needle string) bool {
9881041
return len(haystack) >= len(needle) &&
9891042
(haystack == needle ||

internal/matchers/matcher.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
package matchers
33

44
import (
5+
"slices"
6+
57
"github.com/fiffeek/hyprdynamicmonitors/internal/config"
68
"github.com/fiffeek/hyprdynamicmonitors/internal/hypr"
79
"github.com/fiffeek/hyprdynamicmonitors/internal/power"
@@ -44,7 +46,11 @@ func (m *Matcher) Match(cfg *config.RawConfig, connectedMonitors []*hypr.Monitor
4446
return ok, fallbackProfile, nil
4547
}
4648

47-
for name, profile := range profiles {
49+
// match from the last entry in the toml config
50+
ascProfiles := cfg.OrderedProfileKeys()
51+
slices.Reverse(ascProfiles)
52+
for _, name := range ascProfiles {
53+
profile := cfg.Profiles[name]
4854
if score[name] == bestScore {
4955
return true, profile, nil
5056
}

internal/matchers/matcher_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,33 @@ func TestMatcher_Match(t *testing.T) {
278278
expectedProfile: "",
279279
description: "Profile should not match when name comes from one monitor and description from another",
280280
},
281+
{
282+
name: "last_profile_wins_when_scores_equal",
283+
config: createTestConfig(t, map[string]*config.Profile{
284+
"first_profile": {
285+
Name: "first_profile",
286+
Conditions: &config.ProfileCondition{
287+
RequiredMonitors: []*config.RequiredMonitor{
288+
{Name: utils.StringPtr("eDP-1")},
289+
},
290+
},
291+
},
292+
"second_profile": {
293+
Name: "second_profile",
294+
Conditions: &config.ProfileCondition{
295+
RequiredMonitors: []*config.RequiredMonitor{
296+
{Name: utils.StringPtr("eDP-1")},
297+
},
298+
},
299+
},
300+
}).Get(),
301+
connectedMonitors: []*hypr.MonitorSpec{
302+
{Name: "eDP-1", ID: utils.IntPtr(0), Description: "Built-in Display"},
303+
},
304+
powerState: power.ACPowerState,
305+
expectedProfile: "second_profile",
306+
description: "When two profiles have equal scores, the last one in TOML order should win",
307+
},
281308
}
282309

283310
for _, tt := range tests {

0 commit comments

Comments
 (0)