1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-28 07:22:50 -05:00
Files
DankMaterialShell/core/internal/windowrules/providers/niri_parser.go
bbedward 68159b5c41 niri: add window-rule management
- settings UI for creating, editing, deleting window ruels
- IPC to create a window rule for the currently focused toplevel

fixes #1292
2026-01-27 19:28:58 -05:00

874 lines
24 KiB
Go

package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
type NiriWindowRule struct {
MatchAppID string
MatchTitle string
MatchIsFloating *bool
MatchIsActive *bool
MatchIsFocused *bool
MatchIsActiveInColumn *bool
MatchIsWindowCastTarget *bool
MatchIsUrgent *bool
MatchAtStartup *bool
Opacity *float64
OpenFloating *bool
OpenMaximized *bool
OpenMaximizedToEdges *bool
OpenFullscreen *bool
OpenFocused *bool
OpenOnOutput string
OpenOnWorkspace string
DefaultColumnWidth string
DefaultWindowHeight string
VariableRefreshRate *bool
BlockOutFrom string
DefaultColumnDisplay string
ScrollFactor *float64
CornerRadius *int
ClipToGeometry *bool
TiledState *bool
MinWidth *int
MaxWidth *int
MinHeight *int
MaxHeight *int
BorderColor string
FocusRingColor string
FocusRingOff *bool
BorderOff *bool
DrawBorderWithBg *bool
Source string
}
type NiriRulesParser struct {
configDir string
processedFiles map[string]bool
rules []NiriWindowRule
currentSource string
dmsRulesIncluded bool
dmsRulesExists bool
includeCount int
dmsIncludePos int
rulesAfterDMS int
dmsProcessed bool
}
func NewNiriRulesParser(configDir string) *NiriRulesParser {
return &NiriRulesParser{
configDir: configDir,
processedFiles: make(map[string]bool),
rules: []NiriWindowRule{},
dmsIncludePos: -1,
}
}
func (p *NiriRulesParser) Parse() ([]NiriWindowRule, error) {
dmsRulesPath := filepath.Join(p.configDir, "dms", "windowrules.kdl")
if _, err := os.Stat(dmsRulesPath); err == nil {
p.dmsRulesExists = true
}
configPath := filepath.Join(p.configDir, "config.kdl")
if err := p.parseFile(configPath); err != nil {
return nil, err
}
if p.dmsRulesExists && !p.dmsProcessed {
p.parseDMSRulesDirectly(dmsRulesPath)
}
return p.rules, nil
}
func (p *NiriRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
data, err := os.ReadFile(dmsRulesPath)
if err != nil {
return
}
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return
}
prevSource := p.currentSource
p.currentSource = dmsRulesPath
p.processNodes(doc.Nodes, filepath.Dir(dmsRulesPath))
p.currentSource = prevSource
p.dmsProcessed = true
}
func (p *NiriRulesParser) parseFile(filePath string) error {
absPath, err := filepath.Abs(filePath)
if err != nil {
return err
}
if p.processedFiles[absPath] {
return nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil
}
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return err
}
prevSource := p.currentSource
p.currentSource = absPath
baseDir := filepath.Dir(absPath)
p.processNodes(doc.Nodes, baseDir)
p.currentSource = prevSource
return nil
}
func (p *NiriRulesParser) processNodes(nodes []*document.Node, baseDir string) {
for _, node := range nodes {
name := node.Name.String()
switch name {
case "include":
p.handleInclude(node, baseDir)
case "window-rule":
p.parseWindowRuleNode(node)
}
}
}
func (p *NiriRulesParser) handleInclude(node *document.Node, baseDir string) {
if len(node.Arguments) == 0 {
return
}
includePath := strings.Trim(node.Arguments[0].String(), "\"")
isDMSInclude := includePath == "dms/windowrules.kdl" || strings.HasSuffix(includePath, "/dms/windowrules.kdl")
p.includeCount++
if isDMSInclude {
p.dmsRulesIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := filepath.Join(baseDir, includePath)
if filepath.IsAbs(includePath) {
fullPath = includePath
}
_ = p.parseFile(fullPath)
}
func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
if node.Children == nil {
return
}
rule := NiriWindowRule{
Source: p.currentSource,
}
for _, child := range node.Children {
childName := child.Name.String()
switch childName {
case "match":
p.parseMatchNode(child, &rule)
case "opacity":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if f, ok := val.(float64); ok {
rule.Opacity = &f
}
}
case "open-floating":
b := p.parseBoolArg(child)
rule.OpenFloating = &b
case "open-maximized":
b := p.parseBoolArg(child)
rule.OpenMaximized = &b
case "open-maximized-to-edges":
b := p.parseBoolArg(child)
rule.OpenMaximizedToEdges = &b
case "open-fullscreen":
b := p.parseBoolArg(child)
rule.OpenFullscreen = &b
case "open-focused":
b := p.parseBoolArg(child)
rule.OpenFocused = &b
case "open-on-output":
if len(child.Arguments) > 0 {
rule.OpenOnOutput = child.Arguments[0].ValueString()
}
case "open-on-workspace":
if len(child.Arguments) > 0 {
rule.OpenOnWorkspace = child.Arguments[0].ValueString()
}
case "default-column-width":
rule.DefaultColumnWidth = p.parseSizeNode(child)
case "default-window-height":
rule.DefaultWindowHeight = p.parseSizeNode(child)
case "variable-refresh-rate":
b := p.parseBoolArg(child)
rule.VariableRefreshRate = &b
case "block-out-from":
if len(child.Arguments) > 0 {
rule.BlockOutFrom = child.Arguments[0].ValueString()
}
case "default-column-display":
if len(child.Arguments) > 0 {
rule.DefaultColumnDisplay = child.Arguments[0].ValueString()
}
case "scroll-factor":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if f, ok := val.(float64); ok {
rule.ScrollFactor = &f
}
}
case "geometry-corner-radius":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.CornerRadius = &intVal
}
}
case "clip-to-geometry":
b := p.parseBoolArg(child)
rule.ClipToGeometry = &b
case "tiled-state":
b := p.parseBoolArg(child)
rule.TiledState = &b
case "min-width":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MinWidth = &intVal
}
}
case "max-width":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MaxWidth = &intVal
}
}
case "min-height":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MinHeight = &intVal
}
}
case "max-height":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MaxHeight = &intVal
}
}
case "border":
p.parseBorderNode(child, &rule)
case "focus-ring":
p.parseFocusRingNode(child, &rule)
case "draw-border-with-background":
b := p.parseBoolArg(child)
rule.DrawBorderWithBg = &b
}
}
p.rules = append(p.rules, rule)
}
func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
if node.Children == nil {
return ""
}
for _, child := range node.Children {
name := child.Name.String()
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
switch name {
case "fixed":
if i, ok := val.(int64); ok {
return "fixed " + strconv.FormatInt(i, 10)
}
case "proportion":
if f, ok := val.(float64); ok {
return "proportion " + strconv.FormatFloat(f, 'f', -1, 64)
}
}
}
}
return ""
}
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
if node.Properties == nil {
return
}
if val, ok := node.Properties.Get("app-id"); ok {
rule.MatchAppID = val.ValueString()
}
if val, ok := node.Properties.Get("title"); ok {
rule.MatchTitle = val.ValueString()
}
if val, ok := node.Properties.Get("is-floating"); ok {
b := val.ValueString() == "true"
rule.MatchIsFloating = &b
}
if val, ok := node.Properties.Get("is-active"); ok {
b := val.ValueString() == "true"
rule.MatchIsActive = &b
}
if val, ok := node.Properties.Get("is-focused"); ok {
b := val.ValueString() == "true"
rule.MatchIsFocused = &b
}
if val, ok := node.Properties.Get("is-active-in-column"); ok {
b := val.ValueString() == "true"
rule.MatchIsActiveInColumn = &b
}
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
b := val.ValueString() == "true"
rule.MatchIsWindowCastTarget = &b
}
if val, ok := node.Properties.Get("is-urgent"); ok {
b := val.ValueString() == "true"
rule.MatchIsUrgent = &b
}
if val, ok := node.Properties.Get("at-startup"); ok {
b := val.ValueString() == "true"
rule.MatchAtStartup = &b
}
}
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "off":
b := true
rule.BorderOff = &b
case "active-color":
if len(child.Arguments) > 0 {
rule.BorderColor = child.Arguments[0].ValueString()
}
}
}
}
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "off":
b := true
rule.FocusRingOff = &b
case "active-color":
if len(child.Arguments) > 0 {
rule.FocusRingColor = child.Arguments[0].ValueString()
}
}
}
}
func (p *NiriRulesParser) parseBoolArg(node *document.Node) bool {
if len(node.Arguments) == 0 {
return true
}
return node.Arguments[0].ValueString() != "false"
}
func (p *NiriRulesParser) HasDMSRulesIncluded() bool {
return p.dmsRulesIncluded
}
func (p *NiriRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
status := &windowrules.DMSRulesStatus{
Exists: p.dmsRulesExists,
Included: p.dmsRulesIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS,
}
switch {
case !p.dmsRulesExists:
status.Effective = false
status.StatusMessage = "dms/windowrules.kdl does not exist"
case !p.dmsRulesIncluded:
status.Effective = false
status.StatusMessage = "dms/windowrules.kdl is not included in config.kdl"
case p.rulesAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.rulesAfterDMS
status.StatusMessage = "Some DMS rules may be overridden by config rules"
default:
status.Effective = true
status.StatusMessage = "DMS window rules are active"
}
return status
}
type NiriRulesParseResult struct {
Rules []NiriWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
parser := NewNiriRulesParser(configDir)
rules, err := parser.Parse()
if err != nil {
return nil, err
}
return &NiriRulesParseResult{
Rules: rules,
DMSRulesIncluded: parser.HasDMSRulesIncluded(),
DMSStatus: parser.buildDMSStatus(),
}, nil
}
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(niriRules))
for i, nr := range niriRules {
wr := windowrules.WindowRule{
ID: fmt.Sprintf("rule_%d", i),
Enabled: true,
Source: nr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: nr.MatchAppID,
Title: nr.MatchTitle,
IsFloating: nr.MatchIsFloating,
IsActive: nr.MatchIsActive,
IsFocused: nr.MatchIsFocused,
IsActiveInColumn: nr.MatchIsActiveInColumn,
IsWindowCastTarget: nr.MatchIsWindowCastTarget,
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
OpenMaximized: nr.OpenMaximized,
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
OpenFullscreen: nr.OpenFullscreen,
OpenFocused: nr.OpenFocused,
OpenOnOutput: nr.OpenOnOutput,
OpenOnWorkspace: nr.OpenOnWorkspace,
DefaultColumnWidth: nr.DefaultColumnWidth,
DefaultWindowHeight: nr.DefaultWindowHeight,
VariableRefreshRate: nr.VariableRefreshRate,
BlockOutFrom: nr.BlockOutFrom,
DefaultColumnDisplay: nr.DefaultColumnDisplay,
ScrollFactor: nr.ScrollFactor,
CornerRadius: nr.CornerRadius,
ClipToGeometry: nr.ClipToGeometry,
TiledState: nr.TiledState,
MinWidth: nr.MinWidth,
MaxWidth: nr.MaxWidth,
MinHeight: nr.MinHeight,
MaxHeight: nr.MaxHeight,
BorderColor: nr.BorderColor,
FocusRingColor: nr.FocusRingColor,
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
},
}
result = append(result, wr)
}
return result
}
type NiriWritableProvider struct {
configDir string
}
func NewNiriWritableProvider(configDir string) *NiriWritableProvider {
return &NiriWritableProvider{configDir: configDir}
}
func (p *NiriWritableProvider) Name() string {
return "niri"
}
func (p *NiriWritableProvider) GetOverridePath() string {
return filepath.Join(p.configDir, "dms", "windowrules.kdl")
}
func (p *NiriWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseNiriWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Niri Window Rules",
Provider: "niri",
Rules: ConvertNiriRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *NiriWritableProvider) SetRule(rule windowrules.WindowRule) error {
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
}
found := false
for i, r := range rules {
if r.ID == rule.ID {
rules[i] = rule
found = true
break
}
}
if !found {
rules = append(rules, rule)
}
return p.writeDMSRules(rules)
}
func (p *NiriWritableProvider) RemoveRule(id string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
newRules := make([]windowrules.WindowRule, 0, len(rules))
for _, r := range rules {
if r.ID != id {
newRules = append(newRules, r)
}
}
return p.writeDMSRules(newRules)
}
func (p *NiriWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule)
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
var niriMetaCommentRegex = regexp.MustCompile(`^//\s*@id=(\S*)\s*@name=(.*)$`)
func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
rulesPath := p.GetOverridePath()
data, err := os.ReadFile(rulesPath)
if err != nil {
if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
}
return nil, err
}
content := string(data)
lines := strings.Split(content, "\n")
type ruleMeta struct {
id string
name string
}
var metas []ruleMeta
var currentID, currentName string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if matches := niriMetaCommentRegex.FindStringSubmatch(trimmed); matches != nil {
currentID = matches[1]
currentName = strings.TrimSpace(matches[2])
continue
}
if strings.HasPrefix(trimmed, "window-rule") {
metas = append(metas, ruleMeta{id: currentID, name: currentName})
currentID = ""
currentName = ""
}
}
doc, err := kdl.Parse(strings.NewReader(content))
if err != nil {
return nil, err
}
parser := NewNiriRulesParser(p.configDir)
parser.currentSource = rulesPath
for _, node := range doc.Nodes {
if node.Name.String() == "window-rule" {
parser.parseWindowRuleNode(node)
}
}
var rules []windowrules.WindowRule
for i, nr := range parser.rules {
id := ""
name := ""
if i < len(metas) {
id = metas[i].id
name = metas[i].name
}
if id == "" {
id = fmt.Sprintf("dms_rule_%d", i)
}
wr := windowrules.WindowRule{
ID: id,
Name: name,
Enabled: true,
Source: rulesPath,
MatchCriteria: windowrules.MatchCriteria{
AppID: nr.MatchAppID,
Title: nr.MatchTitle,
IsFloating: nr.MatchIsFloating,
IsActive: nr.MatchIsActive,
IsFocused: nr.MatchIsFocused,
IsActiveInColumn: nr.MatchIsActiveInColumn,
IsWindowCastTarget: nr.MatchIsWindowCastTarget,
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
OpenMaximized: nr.OpenMaximized,
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
OpenFullscreen: nr.OpenFullscreen,
OpenFocused: nr.OpenFocused,
OpenOnOutput: nr.OpenOnOutput,
OpenOnWorkspace: nr.OpenOnWorkspace,
DefaultColumnWidth: nr.DefaultColumnWidth,
DefaultWindowHeight: nr.DefaultWindowHeight,
VariableRefreshRate: nr.VariableRefreshRate,
BlockOutFrom: nr.BlockOutFrom,
DefaultColumnDisplay: nr.DefaultColumnDisplay,
ScrollFactor: nr.ScrollFactor,
CornerRadius: nr.CornerRadius,
ClipToGeometry: nr.ClipToGeometry,
TiledState: nr.TiledState,
MinWidth: nr.MinWidth,
MaxWidth: nr.MaxWidth,
MinHeight: nr.MinHeight,
MaxHeight: nr.MaxHeight,
BorderColor: nr.BorderColor,
FocusRingColor: nr.FocusRingColor,
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
},
}
rules = append(rules, wr)
}
return rules, nil
}
func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
rulesPath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil {
return err
}
var lines []string
lines = append(lines, "// DMS Window Rules - Managed by DankMaterialShell")
lines = append(lines, "// Do not edit manually - changes may be overwritten")
lines = append(lines, "")
for _, rule := range rules {
lines = append(lines, p.formatRule(rule))
lines = append(lines, "")
}
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {")
m := rule.MatchCriteria
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
m.IsUrgent != nil || m.AtStartup != nil {
var matchProps []string
if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
}
if m.Title != "" {
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
}
if m.IsFloating != nil {
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
}
if m.IsActive != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
}
if m.IsFocused != nil {
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
}
if m.IsActiveInColumn != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
}
if m.IsWindowCastTarget != nil {
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
}
if m.IsUrgent != nil {
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
}
if m.AtStartup != nil {
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
}
lines = append(lines, " match "+strings.Join(matchProps, " "))
}
a := rule.Actions
if a.Opacity != nil {
lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity))
}
if a.OpenFloating != nil && *a.OpenFloating {
lines = append(lines, " open-floating true")
}
if a.OpenMaximized != nil && *a.OpenMaximized {
lines = append(lines, " open-maximized true")
}
if a.OpenMaximizedToEdges != nil && *a.OpenMaximizedToEdges {
lines = append(lines, " open-maximized-to-edges true")
}
if a.OpenFullscreen != nil && *a.OpenFullscreen {
lines = append(lines, " open-fullscreen true")
}
if a.OpenFocused != nil {
lines = append(lines, fmt.Sprintf(" open-focused %t", *a.OpenFocused))
}
if a.OpenOnOutput != "" {
lines = append(lines, fmt.Sprintf(" open-on-output %q", a.OpenOnOutput))
}
if a.OpenOnWorkspace != "" {
lines = append(lines, fmt.Sprintf(" open-on-workspace %q", a.OpenOnWorkspace))
}
if a.DefaultColumnWidth != "" {
lines = append(lines, formatSizeProperty("default-column-width", a.DefaultColumnWidth))
}
if a.DefaultWindowHeight != "" {
lines = append(lines, formatSizeProperty("default-window-height", a.DefaultWindowHeight))
}
if a.VariableRefreshRate != nil && *a.VariableRefreshRate {
lines = append(lines, " variable-refresh-rate true")
}
if a.BlockOutFrom != "" {
lines = append(lines, fmt.Sprintf(" block-out-from %q", a.BlockOutFrom))
}
if a.DefaultColumnDisplay != "" {
lines = append(lines, fmt.Sprintf(" default-column-display %q", a.DefaultColumnDisplay))
}
if a.ScrollFactor != nil {
lines = append(lines, fmt.Sprintf(" scroll-factor %.2f", *a.ScrollFactor))
}
if a.CornerRadius != nil {
lines = append(lines, fmt.Sprintf(" geometry-corner-radius %d", *a.CornerRadius))
}
if a.ClipToGeometry != nil && *a.ClipToGeometry {
lines = append(lines, " clip-to-geometry true")
}
if a.TiledState != nil && *a.TiledState {
lines = append(lines, " tiled-state true")
}
if a.MinWidth != nil {
lines = append(lines, fmt.Sprintf(" min-width %d", *a.MinWidth))
}
if a.MaxWidth != nil {
lines = append(lines, fmt.Sprintf(" max-width %d", *a.MaxWidth))
}
if a.MinHeight != nil {
lines = append(lines, fmt.Sprintf(" min-height %d", *a.MinHeight))
}
if a.MaxHeight != nil {
lines = append(lines, fmt.Sprintf(" max-height %d", *a.MaxHeight))
}
if a.BorderOff != nil && *a.BorderOff {
lines = append(lines, " border { off; }")
} else if a.BorderColor != "" {
lines = append(lines, fmt.Sprintf(" border { active-color %q; }", a.BorderColor))
}
if a.FocusRingOff != nil && *a.FocusRingOff {
lines = append(lines, " focus-ring { off; }")
} else if a.FocusRingColor != "" {
lines = append(lines, fmt.Sprintf(" focus-ring { active-color %q; }", a.FocusRingColor))
}
if a.DrawBorderWithBg != nil {
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
}
lines = append(lines, "}")
return strings.Join(lines, "\n")
}
func formatSizeProperty(name, value string) string {
parts := strings.SplitN(value, " ", 2)
if len(parts) != 2 {
return fmt.Sprintf(" %s { }", name)
}
sizeType := parts[0]
sizeValue := parts[1]
return fmt.Sprintf(" %s { %s %s; }", name, sizeType, sizeValue)
}