Initial commit

This commit is contained in:
emilis 2023-01-07 16:56:15 +00:00
commit 23bb7a61ba
21 changed files with 1768 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# Openbsd core dumps
*.core

47
cmdlets/cmdlet.go Normal file
View File

@ -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
}

175
cmdlets/group/group.go Normal file
View File

@ -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)
}

323
cmdlets/init/init.go Normal file
View File

@ -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()
}

84
cmdlets/notify/notify.go Normal file
View File

@ -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,
)
}

33
cmdlets/save/save.go Normal file
View File

@ -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()
}

101
config/config.go Normal file
View File

@ -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)
}

197
config/type.go Normal file
View File

@ -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"},
},
}
)

102
ctllog/colors.go Normal file
View File

@ -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}
)

22
ctllog/colors_test.go Normal file
View File

@ -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)
}
})
}
}

74
ctllog/log.go Normal file
View File

@ -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...))
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module sectorinf.com/emilis/hlctl
go 1.19
require github.com/BurntSushi/toml v1.2.1

2
go.sum Normal file
View File

@ -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=

106
groupctl/group.go Normal file
View File

@ -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
}

103
hlcl/cmd/enum.go Normal file
View File

@ -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"
)

190
hlcl/herbstclient.go Normal file
View File

@ -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
}

29
hlcl/type.go Normal file
View File

@ -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
}
}

80
main.go Normal file
View File

@ -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())
}
}

33
notifyctl/notify.go Normal file
View File

@ -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",
)
}

37
panelctl/panel.go Normal file
View File

@ -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)
}

23
svcctl/svc.go Normal file
View File

@ -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)
}
}
}