diff --git a/conf.go b/conf.go new file mode 100644 index 0000000..6ca8780 --- /dev/null +++ b/conf.go @@ -0,0 +1,208 @@ +package main + +import ( + _ "embed" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" +) +import ( + "github.com/gonutz/w32/v2" + "gopkg.in/yaml.v3" +) + +type KeyBinding struct { + // A repeated value of key modifiers. + // Valid values include: + // SHIFT, ALT, CTRL, WIN (SUPER/META). + Modifier []string `yaml: "modifier"` + // When this is set, this overrides Modifier + ModifierCode []int32 + // Calculated bitwise OR result of modifiers + CombinedMod int32 + // Valid values are: + // A - Z, 0 - 9, UP_ARROW, =, - + // Anything not covered here could be set directly via KeyCode + Key string `yaml: "key"` + // Automatically converted from Key. + // When this is set, it overrides Key. + KeyCode int32 `yaml: "key_code"` + // The feature in RectangleWin to bind to. + // Valid values: + // moveToTop + // moveToBottom + // moveToLeft + // moveToRight + // moveToTopLeft + // moveToTopRight + // moveToBottomLeft + // moveToBottomRight + // makeSmaller + // makeLarger + // makeFullHeight + // + BindFeature string `yaml: "bindfeature"` +} + +type Configuration struct { + Keybindings []KeyBinding `yaml: "key_binding"` +} + +// This mini config is returned if we can't load a valid file +// and cannot write the detailed example yaml config.example.yaml +// into the expected path at %HOME% +var DEFAULT_CONF = Configuration{ + Keybindings: []KeyBinding{ + { + Modifier: []string{"Ctrl", "Alt"}, + Key: "UP_ARROW", + KeyCode: 0x26, + BindFeature: "moveToTop", + }, + }, +} + +//go:embed config.example.yaml +var configExampleYaml []byte + +// Expected config path at %HOME%/.config/RectangleWin/config.yaml +var DEFAULT_CONF_PATH_PREFIX = ".config/RectangleWin/" +var DEFAULT_CONF_NAME = "config.yaml" + +func convertModifier(keyName string) (int32, error) { + switch strings.ToLower(keyName) { + case "ctrl": + return MOD_CONTROL, nil + case "alt": + return MOD_ALT, nil + case "shift": + return MOD_SHIFT, nil + case "win": + case "meta": + case "super": + return MOD_WIN, nil + default: + return 0, fmt.Errorf("invalid keyname: %s", keyName) + } + return 0, errors.New("unreachable") +} + +func convertKeyCode(key string) (int32, error) { + k := strings.ToLower(key) + if len(k) == 1 { + if k[0] >= 'a' && k[0] <= 'z' { + return int32(k[0]) - 32, nil + } + if k[0] >= '0' && k[0] <= '9' { + return int32(k[0]), nil + } + } + switch k { + case "up_arrow": + return w32.VK_UP, nil + case "down_arrow": + return w32.VK_DOWN, nil + case "left_arrow": + return w32.VK_LEFT, nil + case "right_arrow": + return w32.VK_RIGHT, nil + case "-": + return 189, nil + case "=": + return 187, nil + default: + return 0, fmt.Errorf("Unknown key %s", key) + } +} + +func bitwiseOr(nums []int32) int32 { + if len(nums) == 0 { + return 0 + } + result := nums[0] + for _, n := range nums[1:] { + result |= n // bitwise OR + } + return result +} + +func getValidConfigPathOrCreate() (string, error) { + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = os.Getenv("USERPROFILE") + } + if homeDir == "" { + // Give up generating a valid path. + // read or write the conf in current folder. + return DEFAULT_CONF_NAME, errors.New("Failed to find user home directory") + } + configDir := filepath.Join(homeDir, filepath.FromSlash(DEFAULT_CONF_PATH_PREFIX)) + err := os.MkdirAll(configDir, 0755) + if err != nil { + fmt.Printf("Error creating directory under user's home folder: %s", err) + // read or write the conf in current folder + return DEFAULT_CONF_NAME, fmt.Errorf("Failed to create folders under user's home directory: %s", configDir) + } + configPath := filepath.Join(configDir, DEFAULT_CONF_NAME) + return configPath, nil +} + +func maybeDropExampleConfigFile(target string) { + // Check if the file exists, if not, create it with some content + if _, err := os.Stat(target); os.IsNotExist(err) { + // Create the file and write the sample content + err := ioutil.WriteFile(target, configExampleYaml, 0644) + if err != nil { + fmt.Println("Failed to create file created: %s %v", target, err) + } + fmt.Println("File created: %s", target) + } +} + +func fetchConfiguration() Configuration { + // Create a Configuration file. + myConfig := Configuration{} + + // Yaml feeder + configFilePath, err := getValidConfigPathOrCreate() + if err == nil { + maybeDropExampleConfigFile(configFilePath) + } + data, err := os.ReadFile(configFilePath) + if err != nil { + fmt.Printf("Failed to load config file at expected path %s\n", configFilePath) + // use the last-ditch config + return DEFAULT_CONF + } + + if err := yaml.Unmarshal(data, &myConfig); err != nil { + showMessageBox("Failed to parse config file at %s.\n") + return DEFAULT_CONF + } + + for i := range myConfig.Keybindings { + if len(myConfig.Keybindings[i].ModifierCode) == 0 { + for _, mod := range myConfig.Keybindings[i].Modifier { + if modCode, err := convertModifier(mod); err == nil { + myConfig.Keybindings[i].ModifierCode = append(myConfig.Keybindings[i].ModifierCode, modCode) + } else { + fmt.Printf("warn: invalid key name %s", mod) + continue + } + } + } + myConfig.Keybindings[i].CombinedMod = bitwiseOr(myConfig.Keybindings[i].ModifierCode) + if myConfig.Keybindings[i].KeyCode == 0 { + if key, err := convertKeyCode(myConfig.Keybindings[i].Key); err == nil { + myConfig.Keybindings[i].KeyCode = key + } else { + fmt.Printf("warn: invalid key string %s", myConfig.Keybindings[i].Key) + continue + } + } + } + return myConfig +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..24e8407 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,68 @@ +keybindings: + - modifier: + - Ctrl + - Alt + key: UP_ARROW + bindfeature: moveToTop + + - modifier: + - Ctrl + - Alt + key: DOWN_ARROW + bindfeature: moveToBottom + + - modifier: + - Ctrl + - Alt + key: LEFT_ARROW + bindfeature: moveToLeft + + - modifier: + - Ctrl + - Alt + key: RIGHT_ARROW + bindfeature: moveToRight + + - modifier: + - Ctrl + - Alt + key: U + bindfeature: moveToTopLeft + + - modifier: + - Ctrl + - Alt + key: I + bindfeature: moveToTopRight + + - modifier: + - Ctrl + - Alt + key: J + bindfeature: moveToBottomLeft + + - modifier: + - Ctrl + - Alt + key: K + bindfeature: moveToBottomRight + + - modifier: + - Ctrl + - Alt + key: = + bindfeature: makeLarger + + - modifier: + - Ctrl + - Alt + key: "-" + bindfeature: makeSmaller + + - modifier: + - Ctrl + - Alt + - Shift + key: UP_ARROW + bindfeature: makeFullHeight + diff --git a/go.mod b/go.mod index 9155b76..316f34b 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module github.com/ahmetb/RectangleWin go 1.17 require ( + github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 github.com/getlantern/systray v1.1.0 github.com/gonutz/w32/v2 v2.2.2 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab // indirect github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect @@ -16,5 +19,8 @@ require ( github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect github.com/go-stack/stack v1.8.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/stretchr/testify v1.8.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 21b34fd..865e525 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 h1:muXWUcay7DDy1/hEQWrYlBy+g0EuwT70sBHg65SeUc4= +github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY= +github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab h1:CMGzRRCjnD50RjUFSArBLuCxiDvdp7b8YPAcikBEQ+k= +github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= @@ -18,13 +24,35 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gonutz/w32/v2 v2.2.2 h1:y6Y337TpuCXjYdFTq5p5NmcujEdAQiTB43kisovMk+0= github.com/gonutz/w32/v2 v2.2.2/go.mod h1:MgtHx0AScDVNKyB+kjyPder4xIi3XAcHS6LDDU2DmdE= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index cf678f7..dbe7524 100644 --- a/main.go +++ b/main.go @@ -29,11 +29,13 @@ import ( "github.com/gonutz/w32/v2" "github.com/ahmetb/RectangleWin/w32ex" + "github.com/apenwarr/fixconsole" ) var lastResized w32.HWND func main() { + err := fixconsole.FixConsoleIfNeeded() runtime.LockOSThread() // since we bind hotkeys etc that need to dispatch their message here if !w32ex.SetProcessDPIAware() { panic("failed to set DPI aware") @@ -87,6 +89,7 @@ func main() { (HotKey{id: 2, mod: MOD_ALT | MOD_WIN | MOD_NOREPEAT, vk: w32.VK_RIGHT, callback: func() { cycleEdgeFuncs(1) }}), (HotKey{id: 3, mod: MOD_ALT | MOD_WIN | MOD_NOREPEAT, vk: w32.VK_UP, callback: func() { cycleEdgeFuncs(2) }}), (HotKey{id: 4, mod: MOD_ALT | MOD_WIN | MOD_NOREPEAT, vk: w32.VK_DOWN, callback: func() { cycleEdgeFuncs(3) }}), + // Corner func #1 (HotKey{id: 5, mod: MOD_CONTROL | MOD_ALT | MOD_WIN | MOD_NOREPEAT, vk: w32.VK_LEFT, callback: func() { cycleCornerFuncs(0) }}), (HotKey{id: 6, mod: MOD_CONTROL | MOD_ALT | MOD_WIN | MOD_NOREPEAT, vk: w32.VK_UP, callback: func() { cycleCornerFuncs(1) }}), (HotKey{id: 7, mod: MOD_CONTROL | MOD_ALT | MOD_WIN | MOD_NOREPEAT, vk: w32.VK_DOWN, callback: func() { cycleCornerFuncs(2) }}), @@ -115,6 +118,72 @@ func main() { }}), } + myConfig := fetchConfiguration() + // start from id 200 + id := 200 + for _, keyBinding := range myConfig.Keybindings { + switch keyBinding.BindFeature { + case "moveToTop": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleEdgeFuncs(2) }})) + case "moveToBottom": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleEdgeFuncs(3) }})) + case "moveToLeft": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleEdgeFuncs(0) }})) + case "moveToRight": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleEdgeFuncs(1) }})) + case "moveToTopLeft": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleCornerFuncs(0) }})) + case "moveToTopRight": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleCornerFuncs(1) }})) + case "moveToBottomLeft": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleCornerFuncs(2) }})) + case "moveToBottomRight": + id += 1 + hks = append(hks, (HotKey{ + id: id, + mod: int(keyBinding.CombinedMod) | MOD_NOREPEAT, + vk: int(keyBinding.KeyCode), + callback: func() { cycleCornerFuncs(3) }})) + default: + continue + } + } + var failedHotKeys []HotKey for _, hk := range hks { if !RegisterHotKey(hk) { diff --git a/systemwindow.go b/systemwindow.go index 1f60653..298b3a8 100644 --- a/systemwindow.go +++ b/systemwindow.go @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, diff --git a/tray.go b/tray.go index b45aae1..a9ccb84 100644 --- a/tray.go +++ b/tray.go @@ -17,9 +17,9 @@ package main import ( _ "embed" "fmt" - "github.com/getlantern/systray" "github.com/gonutz/w32/v2" + "os/exec" ) //go:embed assets/tray_icon.ico @@ -78,6 +78,29 @@ func onReady() { systray.AddSeparator() + mConfig := systray.AddMenuItem("Configuration", "") + go func() { + <-mConfig.ClickedCh + fmt.Println("opening editor for default config") + configFilePath, err := getValidConfigPathOrCreate() + if err != nil { + showMessageBox(fmt.Sprintf( + "Can't locate config path under user home directory %s\n%v", configFilePath, err)) + return + } + cmd := exec.Command("notepad.exe", configFilePath) + err = cmd.Start() + if err != nil { + showMessageBox(fmt.Sprintf("Failed to open config file %s\n%v", configFilePath, err)) + } + // TODO add a better way to reload current program. + // Reloading programmatically is non-trivial because this program registers + // hotkeys, so it much synchronize to start the child process, but quit + // parent before the child starts to register hotkeys + }() + + systray.AddSeparator() + mQuit := systray.AddMenuItem("Quit", "") go func() { <-mQuit.ClickedCh