commit 23bb7a61ba0795ab355cc490c9ac8285bd3a8c62 Author: emilis Date: Sat Jan 7 16:56:15 2023 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..873cff8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Openbsd core dumps +*.core diff --git a/cmdlets/cmdlet.go b/cmdlets/cmdlet.go new file mode 100644 index 0000000..977e44f --- /dev/null +++ b/cmdlets/cmdlet.go @@ -0,0 +1,47 @@ +package cmdlets + +import ( + "fmt" + "strings" +) + +type Usage struct { + Name string + Short string + Long []string + Flags map[string]string + Examples []string +} + +func (u Usage) String() string { + str := strings.Builder{} + str.WriteString( + fmt.Sprintf("%s SUBCOMMAND\n\n", strings.ToUpper(u.Name)), + ) + str.WriteString(fmt.Sprintf("\t%s\t%s\n\n", u.Name, u.Short)) + for _, line := range u.Long { + str.WriteString(fmt.Sprintf("\t%s\n", line)) + } + str.WriteRune('\n') + if len(u.Flags) != 0 { + for flag, desc := range u.Flags { + str.WriteString(fmt.Sprintf("\t%s\n\t\t%s\n", flag, desc)) + } + str.WriteRune('\n') + } + if len(u.Examples) != 0 { + str.WriteString("EXAMPLES\n\n") + for _, example := range u.Examples { + str.WriteString(fmt.Sprintf("\t%s\n", example)) + } + str.WriteRune('\n') + } + + return str.String() +} + +type Commandlet interface { + Name() string + Usage() Usage + Invoke(args ...string) error +} diff --git a/cmdlets/group/group.go b/cmdlets/group/group.go new file mode 100644 index 0000000..162d383 --- /dev/null +++ b/cmdlets/group/group.go @@ -0,0 +1,175 @@ +package group + +import ( + "errors" + "fmt" + "strconv" + + "sectorinf.com/emilis/hlctl/cmdlets" + "sectorinf.com/emilis/hlctl/config" + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/groupctl" + "sectorinf.com/emilis/hlctl/hlcl" + "sectorinf.com/emilis/hlctl/notifyctl" +) + +var log = ctllog.Logger{}.New("group", ctllog.ColorRGB{}.FromHex("27ee7e")) + +type command struct{} + +var Command command + +func (command) Name() string { + return "group" +} + +func (command) Usage() cmdlets.Usage { + return cmdlets.Usage{ + Name: Command.Name(), + Short: "Controls tag grouping and behavior", + Long: []string{ + "`group` is responsible for controlling how groups behave", + "in hlwm, and switching between them", + }, + Flags: map[string]string{ + "+": "Jump to next group", + "-": "Jump to previous group", + "[number]": "Jump to [number] group", + "move [number]": "Move the currently focused window to group [number]", + }, + Examples: []string{ + "hcctl group +", + "hcctl group -", + "hcctl group 2", + "hcctl group move 2", + }, + } +} + +func (command) Invoke(args ...string) error { + if len(args) == 0 { + return errors.New("group requires arguments") + } + arg := args[0] + if arg == "move" && len(args) < 2 { + return errors.New("group move requires an argument") + } + var active uint8 + currentActiveCfg, err := config.Get(config.ActiveGroup) + if err != nil { + log.Warnf( + "error getting active group, defaulting to 1: %s", + err.Error(), + ) + active = 1 + } else { + val, err := currentActiveCfg.Uint() + mustUint(config.ActiveGroup, err) + active = uint8(val) + } + log.Print("Loading num of tags per group") + numCfg, err := config.Get(config.TagsPerGroup) + if err != nil { + return fmt.Errorf("%s: %w", config.TagsPerGroup, err) + } + num, err := numCfg.Uint() + mustUint(config.TagsPerGroup, err) + log.Print(num, "tags per group") + focus, err := config.GetFocusedTag() + if err != nil { + return fmt.Errorf("get focused tag: %w", err) + } + log.Print("Currently in", focus, "tag") + + maxCfg, err := config.Get(config.GroupCount) + if err != nil { + return fmt.Errorf("%s: %w", config.GroupCount, err) + } + max, err := maxCfg.Uint() + mustUint(config.GroupCount, err) + + var ( + newActive uint8 + ) + + switch arg { + case "+": + newActive = shiftGroup(false, active, uint8(max)) + case "-": + newActive = shiftGroup(true, active, uint8(max)) + case "move": + a, err := strconv.ParseUint(arg, 10, 8) + target := uint8(a) + if err != nil || target < 1 || target > max { + return fmt.Errorf("out of range, use [1:%d]", max) + } + return groupctl.MoveTo(uint8(a)) + default: + a, err := strconv.ParseUint(arg, 10, 8) + target := uint8(a) + if err != nil || target < 1 || target > max { + return fmt.Errorf("out of range, use [1:%d]", max) + } + newActive = uint8(a) + } + + if active != newActive { + log.Printf("Group %d -> %d", active, newActive) + config.Set(config.ActiveGroup, strconv.Itoa(int(newActive))) + // shift the tag index + if err := groupctl.Update(); err != nil { + return fmt.Errorf( + "updating groups [%d -> %d]: %w", + active, + newActive, + err, + ) + } + } + + if err := groupctl.SetKeybinds(newActive, uint8(num)); err != nil { + return fmt.Errorf("SetKeybinds: %w", err) + } + + fg, _ := config.Get(config.ColorText) + bg, _ := config.Get(config.ColorNormal) + font, _ := config.Get(config.Font) + return notifyctl.Display( + fmt.Sprintf("Group %d", newActive), + 2, + fg.String(), + bg.String(), + font.String(), + ) +} + +func moveTo(currentTag, currentGroup, newGroup, num uint8) error { + relTag := (currentTag - ((currentGroup - 1) * num)) + newTag := ((newGroup - 1) * num) + relTag + return hlcl.MoveIndex(uint8(newTag)) +} + +func mustUint(cfg string, err error) { + if err != nil { + panic(fmt.Sprintf( + "%s must be set as uint: %s", + cfg, + err.Error(), + )) + } +} + +func shiftGroup(backwards bool, active, num uint8) uint8 { + // Maintain the logic of the fish scripts, + // 0 == 1, act as if groups are 1 indexed + if backwards && active <= 1 { + active = num + } else if backwards { + active-- + } else if active >= num { + active = 1 + } else { + active++ + } + return uint8(active) +} diff --git a/cmdlets/init/init.go b/cmdlets/init/init.go new file mode 100644 index 0000000..46883c5 --- /dev/null +++ b/cmdlets/init/init.go @@ -0,0 +1,323 @@ +package init + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + + "sectorinf.com/emilis/hlctl/cmdlets" + "sectorinf.com/emilis/hlctl/config" + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/groupctl" + "sectorinf.com/emilis/hlctl/hlcl" + "sectorinf.com/emilis/hlctl/hlcl/cmd" + "sectorinf.com/emilis/hlctl/panelctl" + "sectorinf.com/emilis/hlctl/svcctl" +) + +const ( + Return = "Return" + Shift = "Shift" + Tab = "Tab" + Left = "Left" + Right = "Right" + Up = "Up" + Down = "Down" + Space = "space" + Control = "Control" + Backtick = "grave" +) + +var log = ctllog.Logger{}.New("init", ctllog.Green) + +type command struct{} + +var Command command + +func (command) Name() string { + return "init" +} + +func (command) Usage() cmdlets.Usage { + return cmdlets.Usage{ + Name: Command.Name(), + Short: "Initialize herbstluftwm, should be run from " + + "the autostart script", + Long: []string{ + "`init` starts the configuration of herbstluftwm with the", + "default configuration, setting up keybinds, hlctl groups,", + "and other commands.", + }, + Flags: map[string]string{}, + Examples: []string{ + "hcctl init", + }, + } +} + +func logError(err error) { + if err != nil { + log.Error(err.Error()) + } +} + +type Keybind struct { + Keys []string + Command cmd.Command + Args []string +} + +func (k Keybind) Set() { + err := hlcl.Keybind(k.Keys, k.Command, k.Args...) + if err != nil { + log.Errorf( + "Keybind [%s]: %s", strings.Join(k.Args, "-"), err, + ) + } else { + binds := strings.Join(k.Keys, "-") + command := strings.Join( + append([]string{k.Command.String()}, k.Args...), + " ", + ) + log.Printf("%s -> [%s]", binds, command) + } +} + +func Keybinds(cfg config.HlwmConfig) []Keybind { + // convenience for setting []string + c := func(v ...string) []string { + return v + } + key := func(keys []string, cm cmd.Command, args ...string) Keybind { + return Keybind{ + Keys: keys, + Command: cm, + Args: args, + } + } + Mod := cfg.Mod + step := "+0.05" + base := []Keybind{ + key(c(Mod, Shift, "r"), cmd.Reload), + key(c(Mod, Shift, "c"), cmd.Close), + key(c(Mod, Return), cmd.Spawn, "dmenu_run"), + key(c(Mod, "s"), cmd.Spawn, "flameshot", "gui"), + key(c(Mod, cfg.TermSpawnKey), cmd.Spawn, cfg.Term), + + // Focus + key(c(Mod, Left), cmd.Focus, "left"), + key(c(Mod, Right), cmd.Focus, "right"), + key(c(Mod, Up), cmd.Focus, "up"), + key(c(Mod, Down), cmd.Focus, "down"), + key(c(Mod, Tab), cmd.Cycle), + key(c(Mod, "i"), cmd.JumpTo, "urgent"), + + // Move frames + key(c(Mod, Shift, Left), cmd.Shift, "left"), + key(c(Mod, Shift, Right), cmd.Shift, "right"), + key(c(Mod, Shift, Up), cmd.Shift, "up"), + key(c(Mod, Shift, Down), cmd.Shift, "down"), + + // Frames + key(c(Mod, "u"), cmd.Split, "bottom", "0.7"), + key(c(Mod, "o"), cmd.Split, "right", "0.5"), + key(c(Mod, "r"), cmd.Remove), + key(c(Mod, "f"), cmd.Fullscreen, "toggle"), + key(c(Mod, Space), cmd.CycleLayout), + + // Resizing + key(c(Mod, Control, Left), cmd.Resize, "left", step), + key(c(Mod, Control, Right), cmd.Resize, "right", step), + key(c(Mod, Control, Up), cmd.Resize, "up", step), + key(c(Mod, Control, Down), cmd.Resize, "down", step), + + // Commands + key(c(Mod, Backtick), cmd.Spawn, "hlctl", "group", "+"), + } + + for i := 0; i < int(cfg.Groups.Groups); i++ { + base = append( + base, + key(c(Mod, fmt.Sprintf("F%d", i+1)), cmd.Spawn, + "hlctl", "group", strconv.Itoa(i+1)), + ) + } + return base +} + +type set struct { + name string + value any +} + +func (s set) String() string { + return fmt.Sprint(s.value) +} + +func Theme(cfg config.HlwmConfig) { + s := func(name string, value any) set { + return set{ + name: name, + value: value, + } + } + col := cfg.Theme.Colors + // Theme sets + for _, row := range [...]set{ + s("frame_border_active_color", col.Active), + s("frame_border_normal_color", "black"), + s("frame_bg_active_color", col.Active), + s("frame_border_width", 2), + s("show_frame_decorations", "focused_if_multiple"), + s("tree_style", "╾│ ├└╼─┐"), + s("frame_bg_transparent", "on"), + s("smart_window_surroundings", "off"), // TODO + s("smart_frame_surroundings", "on"), + s("frame_transparent_width", 5), + s("frame_gap", 3), + s("window_gap", 0), + s("frame_padding", 0), + s("mouse_recenter_gap", 0), + } { + value := row.String() + if err := hlcl.Set(row.name, value); err != nil { + log.Errorf("Set [%s] -> [%s]: %s", row.name, value, err) + } + } + // Theme attrs + for _, row := range [...]set{ + s("reset", 1), + s("tiling.reset", 1), + s("floating.reset", 1), + s("title_height", 15), + s("title_when", "multiple_tabs"), + s("title_font", cfg.FontBold), + s("title_depth", 3), + s("inner_width", 0), + s("inner_color", "black"), + s("border_width", 1), + s("floating.border_width", 4), + s("floating.outer_width", 1), + s("tiling.outer_width", 1), + s("background_color", "black"), + + s("active.color", col.Active), + s("active.inner_color", col.Active), + s("active.outer_color", col.Active), + s("normal.color", col.Normal), + s("normal.inner_color", col.Normal), + s("normal.outer_color", col.Normal), + s("urgent.color", col.Urgent), + s("urgent.inner_color", col.Urgent), + s("urgent.outer_color", col.Urgent), + s("title_color", "white"), + s("normal.title_color", col.Text), + } { + attr := fmt.Sprintf("theme.%s", row.name) + v := row.String() + log.Printf("Setting [%s] -> [%s]", attr, v) + if err := hlcl.SetAttr(attr, v); err != nil { + log.Errorf("Attr [%s] -> [%s]: %s", attr, v, err) + } + } +} + +func SetRules() { + sFmt := "windowtype~\"_NET_WM_WINDOW_TYPE_%s\"" + logError(hlcl.Rule("focus=on")) + logError(hlcl.Rule("floatplacement=smart")) + logError(hlcl.Rule("fixedsize", "floating=on")) + logError(hlcl.Rule( + fmt.Sprintf(sFmt, "(DIALOG|UTILITY|SPLASH)"), + "floating=on", + )) + + logError(hlcl.Rule( + fmt.Sprintf(sFmt, "(NOTIFICATION|DOCK|DESKTOP)"), + "manage=off", + )) +} + +func AddTags(groupCount, tagsPerGroup uint8) error { + for group := 0; group < int(groupCount); group++ { + for tag := 0; tag < int(tagsPerGroup); tag++ { + err := hlcl.AddTag(fmt.Sprintf("%d|%d", group+1, tag+1)) + if err != nil { + return fmt.Errorf( + "Adding tag [%d] for group [%d]: %s", + tag+1, group+1, err, + ) + } + } + } + defaultAttr, err := hlcl.GetAttr("tags.by-name.default.index") + if err != nil { + // no default + log.Print("No 'default' tag, continuing...") + return nil + } + focus, err := config.GetFocusedTag() + if err != nil { + return fmt.Errorf("(removing default) getting current tag: %s", err) + } + def, err := strconv.ParseUint(defaultAttr, 10, 8) + if err != nil { + return fmt.Errorf("Bug? default index not uint: %s", err) + } + if uint8(def) == focus { + if err := hlcl.UseIndex(focus + 1); err != nil { + log.Warnf("Tried switching away from default: %s", err) + } + } + if err := hlcl.MergeTag("default"); err != nil { + return fmt.Errorf("merging default: %s", err) + } + + return nil +} + +func (command) Invoke(args ...string) error { + log.Print("begining herbstluftwm setup via hlctl") + log.Print("loading config") + cfg := config.Load() + if err := cfg.Validate(); err != nil { + return fmt.Errorf("validating config: %s", err) + } + if err := cfg.SetAll(); err != nil { + return fmt.Errorf("setting cfg values to hlwm: %s", err) + } + + if err := hlcl.Lock(); err != nil { + return fmt.Errorf("failed lock: %w", err) + } + + exec.Command("xsetroot", "-solid", "black").Run() + logError(hlcl.KeyUnbind()) + + log.Print("Adding tags") + logError(AddTags(cfg.Groups.Groups, cfg.Groups.Tags)) + logError(groupctl.ResetActive()) + + for _, key := range Keybinds(cfg) { + key.Set() + } + log.Print("Resetting groups") + logError(groupctl.ResetActive()) + + log.Print("Theming...") + Theme(cfg) + + log.Print("Rules") + SetRules() + + log.Print("Unlocking") + if err := hlcl.Unlock(); err != nil { + return fmt.Errorf("failed unlock: %w", err) + } + + svcctl.RestartServices(cfg.Services) + + log.Print("Running panels") + return panelctl.Run() +} diff --git a/cmdlets/notify/notify.go b/cmdlets/notify/notify.go new file mode 100644 index 0000000..0e1e0a4 --- /dev/null +++ b/cmdlets/notify/notify.go @@ -0,0 +1,84 @@ +package notify + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "sectorinf.com/emilis/hlctl/cmdlets" + "sectorinf.com/emilis/hlctl/config" + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/notifyctl" +) + +var log = ctllog.Logger{}.New("notify", ctllog.ColorRGB{}.FromHex("ff003e")) + +type command struct{} + +var Command command + +func (command) Name() string { + return "notify" +} + +func (command) Usage() cmdlets.Usage { + return cmdlets.Usage{ + Name: Command.Name(), + Short: "Draws notifications using dzen2", + Long: []string{ + "`notify` Will create notifications using the theme colors", + "that hlwm was set over", + }, + Flags: map[string]string{ + "-s": "Amount of seconds to persist for, default 1", + }, + Examples: []string{ + "notify -s 3 \"Hello world\"", + "notify Hey what's up chicken head", + }, + } +} + +func (command) Invoke(args ...string) error { + var ( + secs string = "1" + ) + cleanArgs := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if len(arg) > 1 && arg[0] == '-' { + var next string + if i+1 < len(args) { + next = args[i+1] + } + switch arg[1] { + case 's': + secs = next + i++ + default: + cleanArgs = append(cleanArgs, arg) + } + } else { + cleanArgs = append(cleanArgs, arg) + } + } + if len(cleanArgs) == 0 { + return errors.New("no message") + } + message := strings.Join(cleanArgs, " ") + cfg := config.Load() + secsNum, err := strconv.ParseUint(secs, 10, 64) + if err != nil { + return fmt.Errorf("seconds[%s]: %w", secs, err) + } + + c := cfg.Theme.Colors + return notifyctl.Display( + message, + uint(secsNum), + c.Text, + c.Normal, + cfg.Font, + ) +} diff --git a/cmdlets/save/save.go b/cmdlets/save/save.go new file mode 100644 index 0000000..b2c9e95 --- /dev/null +++ b/cmdlets/save/save.go @@ -0,0 +1,33 @@ +package save + +import ( + "sectorinf.com/emilis/hlctl/cmdlets" + "sectorinf.com/emilis/hlctl/config" + "sectorinf.com/emilis/hlctl/ctllog" +) + +var log = ctllog.Logger{}.New("save", ctllog.Green) + +type command struct{} + +var Command command + +func (command) Name() string { + return "save" +} + +func (command) Usage() cmdlets.Usage { + return cmdlets.Usage{ + Name: Command.Name(), + Short: "Save the currently loaded configuration to file", + Long: []string{ + "`save` simply saves the configurable state of hlctl", + "to a configuration file located at", + "$HOME/.config/herbstluftwm/hlctl.toml", + }, + } +} + +func (command) Invoke(args ...string) error { + return config.Collect().Save() +} \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..8386324 --- /dev/null +++ b/config/config.go @@ -0,0 +1,101 @@ +package config + +import ( + "errors" + "fmt" + "strconv" + + "sectorinf.com/emilis/hlctl/hlcl" + "sectorinf.com/emilis/hlctl/hlcl/cmd" +) + +const ( + Term = "Term" + Font = "Font" + FontBold = "FontBold" + ModKey = "ModKey" + TermSpawnKey = "TermSpawnKey" + GroupCount = "GroupCount" + TagsPerGroup = "TagsPerGroup" + ActiveGroup = "ActiveGroup" + Services = "Services" + + // Colors + ColorActive = "ColorActive" + ColorNormal = "ColorNormal" + ColorUrgent = "ColorUrgent" + ColorText = "ColorText" +) + +const ( + settingFmt = "settings.my_%s" +) + +var ( + ErrInvalidType = errors.New("invalid type") +) + +type Setting struct { + raw string + valueType string +} + +func (s Setting) String() string { + return s.raw +} + +func (s Setting) Uint() (uint8, error) { + if s.valueType != "uint" { + return 0, fmt.Errorf("%s: %w", s.valueType, ErrInvalidType) + } + val, err := strconv.ParseUint(s.raw, 10, 8) + if err != nil { + panic(fmt.Sprintf( + "hc uint setting[%s] failed to parse: %s", + s.raw, + err.Error(), + )) + } + return uint8(val), nil +} + +func GetFocusedTag() (uint8, error) { + attr, err := hlcl.GetAttr("tags.focus.index") + if err != nil { + return 0, err + } + val, err := strconv.ParseUint(attr, 10, 8) + if err != nil { + return 0, fmt.Errorf("parsing [%s] to uint8: %w", attr, err) + } + return uint8(val), nil +} + +func Get(name string) (Setting, error) { + attrPath := fmt.Sprintf(settingFmt, name) + attr, err := hlcl.GetAttr(attrPath) + if err != nil { + return Setting{}, err + } + attrType, err := hlcl.AttrType(attrPath) + if err != nil { + // Default to string, silently + attrType = "string" + } + return Setting{ + raw: attr, + valueType: attrType, + }, nil +} + +func Set(name string, value string) error { + return hlcl.SetAttr(fmt.Sprintf(settingFmt, name), value) +} + +func New(name string, value string, attrType cmd.AttributeType) error { + if _, err := Get(name); err == nil { + // Already exists, just set value + return Set(name, value) + } + return hlcl.NewAttr(fmt.Sprintf(settingFmt, name), value, attrType) +} diff --git a/config/type.go b/config/type.go new file mode 100644 index 0000000..4e7b99b --- /dev/null +++ b/config/type.go @@ -0,0 +1,197 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/BurntSushi/toml" + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/hlcl/cmd" +) + +var ( + cfgPath = filepath.Join( + os.Getenv("HOME"), + ".config", + "herbstluftwm", + "hlctl.toml", + ) + + log = ctllog.Logger{}.New("config", ctllog.ColorRGB{}.FromHex("ff5aff")) +) + +type ( + HlwmConfig struct { + Font string + FontBold string + Mod string + TermSpawnKey string + Term string + Groups GroupConfig + Theme Theme + Services ServicesConfig + } + + ServicesConfig struct { + Services []string + } + + GroupConfig struct { + Groups uint8 + Tags uint8 + } + + Theme struct { + Colors Colors + } + + Colors struct { + Active string + Normal string + Urgent string + Text string + } +) + +func (h HlwmConfig) Save() error { + cfg, err := os.Create(cfgPath) + if err != nil { + return fmt.Errorf("opening cfg path: [%s]: %w", cfgPath, err) + } + defer cfg.Close() + err = toml.NewEncoder(cfg).Encode(h) + if err != nil { + return fmt.Errorf("encoding config to toml: %w", err) + } + return cfg.Sync() +} + +func (h HlwmConfig) Validate() error { + if h.Font == "" || + h.FontBold == "" || + h.Mod == "" || + h.TermSpawnKey == "" || + h.Term == "" || + h.Groups.Groups == 0 || + h.Groups.Tags == 0 || + len(h.Theme.Colors.Active) < 6 || + len(h.Theme.Colors.Normal) < 6 || + len(h.Theme.Colors.Urgent) < 6 || + len(h.Theme.Colors.Text) < 6 { + return errors.New("some configuration options are missing") + } + return nil +} + +// Collect the config from loaded attributes. Or get default +// if that fails. +func Collect() HlwmConfig { + getOrNothing := func(settingName string, value *string) { + v, err := Get(settingName) + if err != nil { + return + } + *value = v.String() + } + getUints := func(settingName string, value *uint8) { + v, err := Get(settingName) + if err != nil { + return + } + val, err := v.Uint() + if err != nil { + return + } + *value = uint8(val) + } + cfg := Default + getOrNothing(Font, &cfg.Font) + getOrNothing(FontBold, &cfg.FontBold) + getOrNothing(ModKey, &cfg.Mod) + getOrNothing(TermSpawnKey, &cfg.TermSpawnKey) + getOrNothing(Term, &cfg.Term) + getUints(GroupCount, &cfg.Groups.Groups) + getUints(TagsPerGroup, &cfg.Groups.Tags) + getOrNothing(ColorActive, &cfg.Theme.Colors.Active) + getOrNothing(ColorNormal, &cfg.Theme.Colors.Normal) + getOrNothing(ColorUrgent, &cfg.Theme.Colors.Urgent) + getOrNothing(ColorText, &cfg.Theme.Colors.Text) + svcsString, err := Get(Services) + if err == nil { + cfg.Services.Services = strings.Split(svcsString.String(), ";") + } + + return cfg +} + +func (h HlwmConfig) SetAll() error { + err := New(GroupCount, strconv.Itoa(int(h.Groups.Groups)), cmd.Uint) + if err != nil { + return fmt.Errorf("%s: %w", GroupCount, err) + } + + err = New(TagsPerGroup, strconv.Itoa(int(h.Groups.Tags)), cmd.Uint) + if err != nil { + return fmt.Errorf("%s: %w", TagsPerGroup, err) + } + + sets := map[string]string{ + Font: h.Font, + FontBold: h.FontBold, + ModKey: h.Mod, + TermSpawnKey: h.TermSpawnKey, + Term: h.Term, + ColorActive: h.Theme.Colors.Active, + ColorNormal: h.Theme.Colors.Normal, + ColorUrgent: h.Theme.Colors.Urgent, + ColorText: h.Theme.Colors.Text, + Services: strings.Join(h.Services.Services, ";"), + } + for cfg, val := range sets { + if err := New(cfg, val, cmd.String); err != nil { + return fmt.Errorf("%s[%s]: %w", cfg, val, err) + } + } + return nil +} + +func Load() HlwmConfig { + cfg := HlwmConfig{} + if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil { + log.Warnf( + "could not read file [%s] for config, using default", + cfgPath, + ) + return Default + } + return cfg +} + +var ( + Default = HlwmConfig{ + Font: "-*-fixed-medium-*-*-*-12-*-*-*-*-*-*-*", + FontBold: "-*-fixed-bold-*-*-*-12-*-*-*-*-*-*-*", + Mod: "Mod4", + TermSpawnKey: "Home", + Term: "alacritty", + Groups: GroupConfig{ + Groups: 3, + Tags: 5, + }, + Theme: Theme{ + Colors: Colors{ + Active: "#800080", + Normal: "#330033", + Urgent: "#7811A1", + Text: "#898989", + }, + }, + Services: ServicesConfig{ + Services: []string{"fcitx5"}, + }, + } +) \ No newline at end of file diff --git a/ctllog/colors.go b/ctllog/colors.go new file mode 100644 index 0000000..efd15f2 --- /dev/null +++ b/ctllog/colors.go @@ -0,0 +1,102 @@ +package ctllog + +import ( + "encoding/hex" + "fmt" +) + +// \x1b[38;2;255;100;0mTRUECOLOR\x1b[0m +// ESC[ 38;2;⟨r⟩;⟨g⟩;⟨b⟩ m Select RGB foreground color + +const ( + escape byte = 0x1B + csi byte = '[' + separator byte = ';' + csiEnd byte = 'm' + fgColor byte = '3' + bgColor byte = '4' + trueColor byte = '8' + rgb byte = '2' + reset byte = '0' +) + +var ( + trueColorFMT = string([]byte{ + escape, + csi, + '%', 'c', + trueColor, + separator, + rgb, + separator, + '%', 'd', + separator, + '%', 'd', + separator, + '%', 'd', + csiEnd, + '%', 's', + escape, + csi, + reset, + csiEnd, + }) +) + +type Variant byte + +const ( + Foreground = Variant(fgColor) + Background = Variant(bgColor) +) + +type ColorRGB struct { + r uint8 + g uint8 + b uint8 +} + +func (ColorRGB) FromHex(hexString string) ColorRGB { + if len(hexString) < 6 { + return White + } + if hexString[0] == '#' { + hexString = hexString[1:] + } + if len(hexString) != 6 { + return White + } + + res, err := hex.DecodeString(hexString) + if err != nil { + fmt.Println(err.Error()) + return White + } + + return ColorRGB{ + r: res[0], + g: res[1], + b: res[2], + } +} + +func getStr(str string, typeByte Variant, color ColorRGB) string { + return fmt.Sprintf( + trueColorFMT, + typeByte, + color.r, + color.g, + color.b, + str, + ) +} + +var ( + Black ColorRGB + White = ColorRGB{255, 255, 255} + Green = ColorRGB{0, 255, 0} + Red = ColorRGB{255, 0, 0} + Blue = ColorRGB{0, 0, 255} + Purple = ColorRGB{255, 0, 255} + Yellow = ColorRGB{255, 255, 0} +) \ No newline at end of file diff --git a/ctllog/colors_test.go b/ctllog/colors_test.go new file mode 100644 index 0000000..4b00d50 --- /dev/null +++ b/ctllog/colors_test.go @@ -0,0 +1,22 @@ +package ctllog + +import ( + "testing" + // "sectorinf.com/emilis/hlctl/ctllog" +) + +func TestColorFromHex(t *testing.T) { + testCases := map[string]ColorRGB{ + "000000": {}, + "0c2238": {12, 34, 56}, + } + + for hexColor, expected := range testCases { + t.Run(hexColor, func(t *testing.T) { + actual := ColorRGB{}.FromHex(hexColor) + if expected != actual { + t.Fatalf("expected %+v got %+v", expected, actual) + } + }) + } +} diff --git a/ctllog/log.go b/ctllog/log.go new file mode 100644 index 0000000..2a7b304 --- /dev/null +++ b/ctllog/log.go @@ -0,0 +1,74 @@ +package ctllog + +import ( + "fmt" + "os" + "runtime/debug" + "strings" +) + +type Logger struct { + coloredName string +} + +func (Logger) New(name string, color ColorRGB) Logger { + coloredName := getStr("[ "+name+" ]", Foreground, color) + return Logger{ + coloredName: coloredName, + } +} + +func (l Logger) print(str string) { + lines := strings.Split(str, "\n") + for i, line := range lines { + lines[i] = fmt.Sprintf("%s: %s", l.coloredName, line) + } + str = strings.Join(lines, "\n") + fmt.Fprintln(os.Stderr, str) +} + +func (l Logger) colored(color ColorRGB, args ...any) { + l.print(getStr(fmt.Sprint(args...), Foreground, color)) +} + +func (l Logger) coloredf(color ColorRGB, format string, args ...any) { + l.print(getStr(fmt.Sprintf(format, args...), Foreground, color)) +} + +func (l Logger) fatal(str string) { + l.print(getStr(str, Background, Red)) + debug.PrintStack() + os.Exit(16) +} + +func (l Logger) Print(args ...any) { + l.print(fmt.Sprint(args...)) +} + +func (l Logger) Printf(format string, args ...any) { + l.print(fmt.Sprintf(format, args...)) +} + +func (l Logger) Warn(args ...any) { + l.colored(Yellow, args...) +} + +func (l Logger) Warnf(format string, args ...any) { + l.coloredf(Yellow, format, args...) +} + +func (l Logger) Error(args ...any) { + l.colored(Red, args...) +} + +func (l Logger) Errorf(format string, args ...any) { + l.coloredf(Red, format, args...) +} + +func (l Logger) Fatal(args ...any) { + l.fatal(fmt.Sprint(args...)) +} + +func (l Logger) Fatalf(format string, args ...any) { + l.fatal(fmt.Sprintf(format, args...)) +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b2e6113 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module sectorinf.com/emilis/hlctl + +go 1.19 + +require github.com/BurntSushi/toml v1.2.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4614a74 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= diff --git a/groupctl/group.go b/groupctl/group.go new file mode 100644 index 0000000..5734061 --- /dev/null +++ b/groupctl/group.go @@ -0,0 +1,106 @@ +package groupctl + +import ( + "fmt" + "strconv" + + "sectorinf.com/emilis/hlctl/config" + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/hlcl" + "sectorinf.com/emilis/hlctl/hlcl/cmd" +) + +var log = ctllog.Logger{}.New("group", ctllog.ColorRGB{}.FromHex("27ee7e")) + +func ResetActive() error { + err := config.New(config.ActiveGroup, "1", cmd.Uint) + if err != nil { + return fmt.Errorf("new active group: %w", err) + } + + return Update() +} + +// Update switches to the correct tag based on the active group +func Update() error { + activeCfg, err := config.Get(config.ActiveGroup) + if err != nil { + return fmt.Errorf("%s: %w", config.ActiveGroup, err) + } + active, err := activeCfg.Uint() + if err != nil { + return fmt.Errorf("%s: %w", config.ActiveGroup, err) + } + focus, err := config.GetFocusedTag() + if err != nil { + return fmt.Errorf("focus: %w", err) + } + numCfg, err := config.Get(config.TagsPerGroup) + if err != nil { + return fmt.Errorf("get %s: %w", config.TagsPerGroup, err) + } + num, err := numCfg.Uint() + if err != nil { + return fmt.Errorf("%s: %w", config.TagsPerGroup, err) + } + relativeTag := focus % uint8(num) + absTag := ((active - 1) * num) + relativeTag + if focus != absTag { + log.Printf("Tag %d -> %d", focus, absTag) + } + if err := hlcl.UseIndex(absTag); err != nil { + return fmt.Errorf("UseIndex[%d]: %w", absTag, err) + } + + return SetKeybinds(active, num) +} + +func MoveTo(group uint8) error { + numCfg, err := config.Get(config.TagsPerGroup) + if err != nil { + return fmt.Errorf("get %s: %w", config.TagsPerGroup, err) + } + num, err := numCfg.Uint() + if err != nil { + return fmt.Errorf("%s: %w", config.TagsPerGroup, err) + } + + focus, err := config.GetFocusedTag() + if err != nil { + return fmt.Errorf("focus: %w", err) + } + relative := focus % num + newTag := ((group - 1) * num) + relative + log.Printf("Moving window to tag -> %d", newTag) + return hlcl.MoveIndex(newTag) +} + +func SetKeybinds(group uint8, num uint8) error { + offset := int((group - 1) * num) + mod, err := config.Get(config.ModKey) + if err != nil { + return fmt.Errorf("%s: %w", config.ModKey, err) + } + log.Printf("Setting %d keybinds for group %d", num, group) + for i := 0; i < int(num); i++ { + actualTag := strconv.Itoa(offset + i) + tag := strconv.Itoa(i + 1) + err = hlcl.Keybind( + []string{mod.String(), tag}, + cmd.UseIndex, + actualTag, + ) + if err != nil { + return fmt.Errorf("Setting use_index for [%d]: %w", i+1, err) + } + err = hlcl.Keybind( + []string{mod.String(), "Shift", tag}, + cmd.MoveIndex, + actualTag, + ) + if err != nil { + return fmt.Errorf("Setting move_index for [%d]: %w", i+1, err) + } + } + return nil +} \ No newline at end of file diff --git a/hlcl/cmd/enum.go b/hlcl/cmd/enum.go new file mode 100644 index 0000000..1b77aaf --- /dev/null +++ b/hlcl/cmd/enum.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "fmt" + "strings" + "unicode" +) + +type Command string + +func (c Command) String() string { + return string(c) +} + +func (c Command) Pretty() string { + parts := strings.Split(string(c), "_") + for i, part := range parts { + // Set the first character to be upper case + if len(part) == 0 { + continue + } else if len(part) == 1 { + parts[i] = strings.ToUpper(part) + } + var first rune + for _, r := range part { + first = r + break + } + + parts[i] = fmt.Sprintf("%c%s", unicode.ToUpper(first), part[1:]) + } + return strings.Join(parts, "") +} + +const ( + Reload Command = "reload" + Close Command = "close" + Spawn Command = "spawn" + ListMonitors Command = "list_monitors" + Lock Command = "lock" + Unlock Command = "unlock" + + // Attr family + GetAttr Command = "get_attr" + SetAttr Command = "set_attr" + Attr Command = "attr" + NewAttr Command = "new_attr" + AttrType Command = "attr_type" + RemoveAttr Command = "remove_attr" + + Set Command = "set" + EmitHook Command = "emit_hook" + Rule Command = "rule" + Unrule Command = "unrule" + Substitute Command = "substitute" + + // Controls + Keybind Command = "keybind" + Keyunbind Command = "keyunbind" + Mousebind Command = "mousebind" + Mouseunbind Command = "mouseunbind" + UseIndex Command = "use_index" + MoveIndex Command = "move_index" + JumpTo Command = "jumpto" + + // Views/Frames + AddTag Command = "add" + MergeTag Command = "merge_tag" + Cycle Command = "cycle" + Focus Command = "focus" + Shift Command = "shift" + Split Command = "split" + Remove Command = "remove" + Fullscreen Command = "fullscreen" + CycleLayout Command = "cycle_layout" + Resize Command = "resize" +) + +type MouseButton string + +func (m MouseButton) String() string { + return string(m) +} + +const ( + Mouse1 MouseButton = "Mouse1" + Mouse2 MouseButton = "Mouse2" + Mouse3 MouseButton = "Mouse3" +) + +type AttributeType string + +func (a AttributeType) String() string { + return string(a) +} + +const ( + String AttributeType = "string" + Bool AttributeType = "bool" + Color AttributeType = "color" + Int AttributeType = "int" + Uint AttributeType = "uint" +) diff --git a/hlcl/herbstclient.go b/hlcl/herbstclient.go new file mode 100644 index 0000000..69c0eb6 --- /dev/null +++ b/hlcl/herbstclient.go @@ -0,0 +1,190 @@ +// package hlcl contains logic for interacting with the +// herbstclient +package hlcl + +import ( + "bytes" + "fmt" + "io" + "os/exec" + "strconv" + "strings" + + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/hlcl/cmd" +) + +var log = ctllog.Logger{}.New("hlcl", ctllog.Purple) + +func runGeneric(command string, stdin string, args ...string) (string, error) { + cmd := exec.Command(command, args...) + // log.Printf("Running command [%s] with args: %v", command, args) + var stdout, stderr = bytes.Buffer{}, bytes.Buffer{} + cmd.Stderr = &stderr + cmd.Stdout = &stdout + if stdin != "" { + pipe, err := cmd.StdinPipe() + if err != nil { + return "", fmt.Errorf("stdin pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start: %w", err) + } + io.WriteString(pipe, stdin+"\n") + _, err = pipe.Write([]byte(stdin)) + if err != nil { + return "", fmt.Errorf("write stdin: %w", err) + } + pipe.Close() + } else { + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start: %w", err) + } + } + if err := cmd.Wait(); err != nil { + status, ok := err.(*exec.ExitError) + if !ok { + return "", fmt.Errorf("%s exec error: %w", command, err) + } + exitCode := status.ExitCode() + return "", fmt.Errorf( + "%s error (%d): %s", + command, + exitCode, + strings.TrimSpace(stderr.String()), + ) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func run(command cmd.Command, args ...string) (string, error) { + args = append([]string{command.String()}, args...) + return runGeneric("herbstclient", "", args...) +} + +// query executes a command and pretty formats and error message +// based on the command's Pretty(), and returns the output +func query(c cmd.Command, args ...string) (string, error) { + value, err := run(c, args...) + if err != nil { + return value, fmt.Errorf( + "%s(%s): %w", + c.Pretty(), + strings.Join(args, ", "), + err, + ) + } + return value, nil +} + +// execute: wrapper for [query], discarding the stdout result +func execute(c cmd.Command, args ...string) error { + _, err := query(c, args...) + return err +} + +func NewAttr(path string, value string, aType cmd.AttributeType) error { + return execute(cmd.NewAttr, aType.String(), path, value) +} + +func SetAttr(path string, value string) error { + return execute(cmd.SetAttr, path, value) +} + +func GetAttr(path string) (string, error) { + return query(cmd.GetAttr, path) +} + +func AttrType(path string) (string, error) { + return query(cmd.AttrType, path) +} + +func KeyUnbind() error { + return execute(cmd.Keyunbind, "--all") +} + +func MouseUnbind() error { + return execute(cmd.Mouseunbind, "--all") +} + +func ListMonitors() ([]Screen, error) { + out, err := query(cmd.ListMonitors) + if err != nil { + return nil, err + } + outs := strings.Split(out, "\n") + scrs := make([]Screen, len(outs)) + for i, line := range outs { + scrs[i] = Screen{}.FromString(line) + } + return scrs, nil +} + +func bindCMD( + bindCommand cmd.Command, + keys []string, + command cmd.Command, + args ...string, +) error { + bindKeys := strings.Join(keys, "-") + args = append([]string{bindKeys, command.String()}, args...) + return execute(bindCommand, args...) +} + +func Keybind(keys []string, command cmd.Command, args ...string) error { + return bindCMD(cmd.Keybind, keys, command, args...) +} + +func Mousebind(keys []string, command cmd.Command, args ...string) error { + return bindCMD(cmd.Mousebind, keys, command, args...) +} + +func Set(name, value string) error { + return execute(cmd.Set, name, value) +} + +func UseIndex(index uint8) error { + return execute(cmd.UseIndex, strconv.Itoa(int(index))) +} + +func MergeTag(name string) error { + return execute(cmd.MergeTag, name) +} + +func MoveIndex(index uint8) error { + return execute(cmd.MoveIndex, strconv.Itoa(int(index))) +} + +func TextWidth(font, message string) (string, error) { + return runGeneric("textwidth", "", font, message) +} + +func Rule(name string, args ...string) error { + return execute(cmd.Rule, append([]string{name}, args...)...) +} + +func Unrule() error { + return execute(cmd.Unrule, "-F") +} + +func SilentSpawn(command string, args ...string) error { + return execute(cmd.Spawn, append([]string{command}, args...)...) +} + +func Lock() error { + return execute(cmd.Lock) +} + +func Unlock() error { + return execute(cmd.Unlock) +} + +func AddTag(name string) error { + return execute(cmd.AddTag, name) +} + +func Dzen2(message string, args ...string) error { + _, err := runGeneric("dzen2", message, args...) + return err +} diff --git a/hlcl/type.go b/hlcl/type.go new file mode 100644 index 0000000..cc742e0 --- /dev/null +++ b/hlcl/type.go @@ -0,0 +1,29 @@ +package hlcl + +import "strings" + +type Screen struct { + ID string + Resolution string + WithTag string + Focused bool +} + +func (Screen) FromString(v string) Screen { + parts := strings.Split(v, " ") + if len(parts) < 5 { + return Screen{ + ID: v[:1], + } + } + for i, part := range parts { + parts[i] = strings.TrimSpace( + strings.TrimRight( + strings.Trim(part, "\""), ":")) + } + return Screen{ + ID: parts[0], + Resolution: parts[1], + // Maybe later + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5ec6d62 --- /dev/null +++ b/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "sectorinf.com/emilis/hlctl/cmdlets" + "sectorinf.com/emilis/hlctl/cmdlets/group" + initcmd "sectorinf.com/emilis/hlctl/cmdlets/init" + "sectorinf.com/emilis/hlctl/cmdlets/notify" + "sectorinf.com/emilis/hlctl/cmdlets/save" + "sectorinf.com/emilis/hlctl/ctllog" +) + +var log = ctllog.Logger{}.New("hlctl", ctllog.Green) + +var cmds = []cmdlets.Commandlet{ + initcmd.Command, + group.Command, + notify.Command, + save.Command, +} + +var ( + ErrNotFound = errors.New("not found") +) + +func getCmdlet(name string) (cmdlets.Commandlet, error) { + for _, c := range cmds { + if c.Name() == name { + return c, nil + } + } + return nil, fmt.Errorf("%s: %w", name, ErrNotFound) +} + +func help() { + commands := make([]string, len(cmds)) + for i, c := range cmds { + commands[i] = c.Name() + } + fmt.Fprint(os.Stderr, "USAGE: hlctl [command] [arguments]\n") + fmt.Fprintf(os.Stderr, "\nAvailable commands: %v\n", commands) + fmt.Fprint(os.Stderr, "\nSee hlctl help [command] for details\n") +} + +func main() { + // omit the program name + args := os.Args[1:] + if len(args) == 0 { + help() + os.Exit(1) + } + command := strings.ToLower(args[0]) + if command == "help" { + if len(args) == 1 { + help() + return + } + name := args[1] + c, err := getCmdlet(name) + if err != nil { + help() + os.Exit(1) + } + fmt.Println(c.Usage()) + return + } + c, err := getCmdlet(command) + if err != nil { + help() + os.Exit(1) + } + + if err := c.Invoke(args[1:]...); err != nil { + log.Fatalf(" %s > %s", c.Name(), err.Error()) + } +} diff --git a/notifyctl/notify.go b/notifyctl/notify.go new file mode 100644 index 0000000..f8eb228 --- /dev/null +++ b/notifyctl/notify.go @@ -0,0 +1,33 @@ +package notifyctl + +import ( + "strconv" + + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/hlcl" +) + +var log = ctllog.Logger{}.New("notify", ctllog.ColorRGB{}.FromHex("ff003e")) + +func Display(message string, duration uint, fg, bg, font string) error { + width, err := hlcl.TextWidth(font, message) + if err != nil { + log.Errorf("%s; defaulting to width 100", err.Error()) + width = "100" + } + widthNum, err := strconv.ParseUint(width, 10, 8) + if err != nil { + panic(err) + } + + return hlcl.Dzen2( + message, + "-p", strconv.Itoa(int(duration)), + "-fg", fg, + "-bg", bg, + "-fn", font, + "-ta", "c", + "-w", strconv.Itoa(int(widthNum)+30), + "-h", "16", + ) +} diff --git a/panelctl/panel.go b/panelctl/panel.go new file mode 100644 index 0000000..a8dfa0d --- /dev/null +++ b/panelctl/panel.go @@ -0,0 +1,37 @@ +package panelctl + +import ( + "fmt" + "log" + "os" + "os/exec" + + "sectorinf.com/emilis/hlctl/hlcl" +) + +func Run() error { + screens, err := hlcl.ListMonitors() + if err != nil { + return err + } + panel := func(mon string) error { + return exec.Command( + "bash", + fmt.Sprintf( + "%s/.config/herbstluftwm/panel.sh", + os.Getenv("HOME"), + ), + mon, + ).Run() + } + for i := 1; i < len(screens); i++ { + id := screens[i].ID + go func() { + if err := panel(id); err != nil { + log.Fatalf("Monitor [%s] panel: %s", id, err) + } + }() + } + + return panel(screens[0].ID) +} diff --git a/svcctl/svc.go b/svcctl/svc.go new file mode 100644 index 0000000..8b96971 --- /dev/null +++ b/svcctl/svc.go @@ -0,0 +1,23 @@ +package svcctl + +import ( + "os/exec" + + "sectorinf.com/emilis/hlctl/config" + "sectorinf.com/emilis/hlctl/ctllog" + "sectorinf.com/emilis/hlctl/hlcl" +) + +var log = ctllog.Logger{}.New("config", ctllog.ColorRGB{}.FromHex("2e8647")) + +func RestartServices(cfg config.ServicesConfig) { + for _, svc := range cfg.Services { + log.Printf("Restarting [%s]", svc) + if err := exec.Command("pkill", "-9", svc).Run(); err != nil { + log.Errorf("Killing [%s]: %s", svc, err) + } + if err := hlcl.SilentSpawn(svc); err != nil { + log.Errorf("Starting [%s]: %s", svc, err) + } + } +} \ No newline at end of file