From 707df1a3a1c48dffea4e79ce46eda253464a356c Mon Sep 17 00:00:00 2001 From: Liam Galvin Date: Sun, 25 Nov 2018 14:25:19 +0000 Subject: [PATCH] added configurable keyboard shortcuts --- README.md | 66 +++++++++++++++++++------ config/actions.go | 12 +++++ config/config.go | 17 ++++--- config/defaults.go | 22 +++++++++ config/keys.go | 116 ++++++++++++++++++++++++++++++++++++++++++++ config/keys_test.go | 27 +++++++++++ gui/actions.go | 47 ++++++++++++++++++ gui/gui.go | 61 ++++++++++++----------- gui/input.go | 49 +++++++++---------- main.go | 5 +- 10 files changed, 345 insertions(+), 77 deletions(-) create mode 100644 config/actions.go create mode 100644 config/keys.go create mode 100644 config/keys_test.go create mode 100644 gui/actions.go diff --git a/README.md b/README.md index cf235a7..39a9b15 100644 --- a/README.md +++ b/README.md @@ -76,19 +76,19 @@ make install As long as you have your `GOBIN` environment variable set up properly (and in `PATH`), you should be able to run `aminal`. -## Keyboard Shortcuts +## Keyboard/Mouse Shortcuts | Operation | Key(s) | | -------------------- | -------------------- | | Select text | click + drag | | Select word | double click | | Select line | triple click | -| Copy | ctrl + shift + c | -| Toggle debug display | ctrl + shift + d | -| Paste | ctrl + shift + v | -| Google selected text | ctrl + shift + g | -| Report bug in aminal | ctrl + shift + r | -| Toggle slomo | ctrl + shift + ; | +| Copy | `ctrl + shift + c` (Mac: `super + c`) | +| Paste | `ctrl + shift + v` (Mac: `super + v`) | +| Google selected text | `ctrl + shift + g` (Mac: `super + g`) | +| Toggle debug display | `ctrl + shift + d` (Mac: `super + d`) | +| Toggle slomo | `ctrl + shift + ;` (Mac: `super + ;`) | +| Report bug in aminal | `ctrl + shift + r` (Mac: `super + r`) | ## Configuration @@ -96,11 +96,49 @@ Aminal looks for a config file in `~/.aminal.toml`, and will write one there the You can ignore the config and use defaults by specifying `--ignore-config` as a CLI flag. -### Config Options/CLI Flags +### Config File -| CLI Flag | Config Section | Config Name | Type | Default | Description | -| --------------- | -------------- | ----------- | ------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------- | -| --debug | _root_ | debug | boolean | false | Enable debug mode, with debug logging and debug info terminal overlay. -| --slomo | _root_ | slomo | boolean | false | Enable slomo mode, delay the handling of each incoming byte (or escape sequence) from the pty by 100ms. Useful for debugging. -| --shell [shell] | _root_ | shell | string | User's shell | Use the specified shell program instead of the user's usual one. -| --version | n/a | n/a | boolean | n/a | Show the version of aminal. +```toml +debug = false # Enable debug logging to stdout. Defaults to false. +slomo = false # Enable slow motion output mode, useful for debugging shells/terminal GUI apps etc. Defaults to false. +shell = "/bin/bash" # The shell to run for the terminal session. Defaults to the users shell. + +[colours] + cursor = "#e8dfd6" + foreground = "#e8dfd6" + background = "#021b21" + black = "#032c36" + red = "#c2454e" + green = "#7cbf9e" + yellow = "#8a7a63" + blue = "#065f73" + magenta = "#ff5879" + cyan = "#44b5b1" + light_grey = "#f2f1b9" + dark_grey = "#3e4360" + light_red = "#ef5847" + light_green = "#a2db91" + light_yellow = "#beb090" + light_blue = "#61778d" + light_magenta = "#ff99a1" + light_cyan = "#9ed9d8" + white = "#f6f6c9" + selection = "#333366" # Mouse selection background colour + +[keys] + copy = "ctrl + shift + c" # Copy highlighted text to system clipboard + paste = "ctrl + shift + v" # Paste text from system clipboard + debug = "ctrl + shift + d" # Toggle debug panel overlay + google = "ctrl + shift + g" # Google selected text + report = "ctrl + shift + r" # Send bug report + slomo = "ctrl + shift + ;" # Toggle slow motion output mode (useful for debugging) +``` + +### CLI Flags + +| Flag | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `--debug` | Enable debug mode, with debug logging and debug info terminal overlay. +| `--slomo` | Enable slomo mode, delay the handling of each incoming byte (or escape sequence) from the pty by 100ms. Useful for debugging. +| `--shell [shell]` | Use the specified shell program instead of the user's usual one. +| `--version` | Show the version of aminal and exit. diff --git a/config/actions.go b/config/actions.go new file mode 100644 index 0000000..b891b1f --- /dev/null +++ b/config/actions.go @@ -0,0 +1,12 @@ +package config + +type UserAction string + +const ( + ActionCopy UserAction = "copy" + ActionPaste UserAction = "paste" + ActionGoogle UserAction = "google" + ActionReportBug UserAction = "report" + ActionToggleDebug UserAction = "debug" + ActionToggleSlomo UserAction = "slomo" +) diff --git a/config/config.go b/config/config.go index 24ee94e..15b2dc7 100644 --- a/config/config.go +++ b/config/config.go @@ -7,20 +7,21 @@ import ( ) type Config struct { - DebugMode bool `toml:"debug"` - Rendering RenderingConfig `toml:"rendering"` - Slomo bool `toml:"slomo"` - ColourScheme ColourScheme `toml:"colours"` - Shell string `toml:"shell"` + DebugMode bool `toml:"debug"` + Slomo bool `toml:"slomo"` + ColourScheme ColourScheme `toml:"colours"` + Shell string `toml:"shell"` + KeyMapping KeyMappingConfig `toml:"keys"` } -type RenderingConfig struct { - AlwaysRepaint bool `toml:"always_repaint"` -} +type KeyMappingConfig map[string]string func Parse(data []byte) (*Config, error) { c := DefaultConfig err := toml.Unmarshal(data, &c) + if c.KeyMapping == nil { + c.KeyMapping = KeyMappingConfig(map[string]string{}) + } return &c, err } diff --git a/config/defaults.go b/config/defaults.go index a4c32e1..2282608 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -1,5 +1,7 @@ package config +import "runtime" + var DefaultConfig = Config{ DebugMode: false, ColourScheme: ColourScheme{ @@ -24,4 +26,24 @@ var DefaultConfig = Config{ White: strToColourNoErr("#f6f6c9"), Selection: strToColourNoErr("#333366"), }, + KeyMapping: KeyMappingConfig(map[string]string{}), +} + +func init() { + DefaultConfig.KeyMapping[string(ActionCopy)] = addMod("c") + DefaultConfig.KeyMapping[string(ActionPaste)] = addMod("v") + DefaultConfig.KeyMapping[string(ActionGoogle)] = addMod("g") + DefaultConfig.KeyMapping[string(ActionToggleDebug)] = addMod("d") + DefaultConfig.KeyMapping[string(ActionToggleSlomo)] = addMod(";") + DefaultConfig.KeyMapping[string(ActionReportBug)] = addMod("r") +} + +func addMod(keys string) string { + standardMod := "ctrl + shift + " + + if runtime.GOOS == "darwin" { + standardMod = "super + " + } + + return standardMod + keys } diff --git a/config/keys.go b/config/keys.go new file mode 100644 index 0000000..52ea135 --- /dev/null +++ b/config/keys.go @@ -0,0 +1,116 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/go-gl/glfw/v3.2/glfw" +) + +type KeyCombination struct { + mods glfw.ModifierKey + key glfw.Key +} + +type KeyMod string + +const ( + ctrl KeyMod = "ctrl" + alt KeyMod = "alt" + shift KeyMod = "shift" + super KeyMod = "super" +) + +var modMap = map[KeyMod]glfw.ModifierKey{ + ctrl: glfw.ModControl, + alt: glfw.ModAlt, + shift: glfw.ModShift, + super: glfw.ModSuper, +} + +var keyMap = map[string]glfw.Key{ + "a": glfw.KeyA, + "b": glfw.KeyB, + "c": glfw.KeyC, + "d": glfw.KeyD, + "e": glfw.KeyE, + "f": glfw.KeyF, + "g": glfw.KeyG, + "h": glfw.KeyH, + "i": glfw.KeyI, + "j": glfw.KeyJ, + "k": glfw.KeyK, + "l": glfw.KeyL, + "m": glfw.KeyM, + "n": glfw.KeyN, + "o": glfw.KeyO, + "p": glfw.KeyP, + "q": glfw.KeyQ, + "r": glfw.KeyR, + "s": glfw.KeyS, + "t": glfw.KeyT, + "u": glfw.KeyU, + "v": glfw.KeyV, + "w": glfw.KeyW, + "x": glfw.KeyX, + "y": glfw.KeyY, + "z": glfw.KeyZ, + ";": glfw.KeySemicolon, +} + +// keyStr e.g. "ctrl + alt + a" +func parseKeyCombination(keyStr string) (*KeyCombination, error) { + + var mods glfw.ModifierKey + var key *glfw.Key + + keys := strings.Split(keyStr, "+") + for _, k := range keys { + k = strings.ToLower(strings.TrimSpace(k)) + mod, ok := modMap[KeyMod(k)] + if ok { + mods = mods + mod + continue + } + mappedKey, ok := keyMap[k] + if ok { + if key != nil { + return nil, fmt.Errorf("Multiple non-modifier keys specified in keyboard shortcut") + } + key = &mappedKey + continue + } + + return nil, fmt.Errorf("Unknown key '%s' in configured keyboard shortcut", k) + } + + if key == nil { + return nil, fmt.Errorf("No non-modifier key specified in keyboard shortcut") + } + + if mods == 0 { + return nil, fmt.Errorf("No modifier key specified in keyboard shortcut") + } + + return &KeyCombination{ + mods: mods, + key: *key, + }, nil +} + +func (combi KeyCombination) Match(pressedMods glfw.ModifierKey, pressedKey glfw.Key) bool { + return pressedKey == combi.key && pressedMods == combi.mods +} + +func (keyMapConfig KeyMappingConfig) GenerateActionMap() (map[UserAction]*KeyCombination, error) { + m := map[UserAction]*KeyCombination{} + for actionStr, keyStr := range keyMapConfig { + combi, err := parseKeyCombination(keyStr) + if err != nil { + return nil, err + } + m[UserAction(actionStr)] = combi + } + + return m, nil +} diff --git a/config/keys_test.go b/config/keys_test.go new file mode 100644 index 0000000..1508ce3 --- /dev/null +++ b/config/keys_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "testing" + + "github.com/go-gl/glfw/v3.2/glfw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKeyCombinations(t *testing.T) { + + combi, err := parseKeyCombination("ctrl + alt + a") + require.Nil(t, err) + require.NotNil(t, combi) + + assert.Equal(t, glfw.KeyA, combi.key) + assert.Equal(t, glfw.ModControl+glfw.ModAlt, combi.mods) + + assert.True(t, combi.Match(glfw.ModControl^glfw.ModAlt, glfw.KeyA)) + assert.False(t, combi.Match(glfw.ModControl^glfw.ModAlt, glfw.KeyB)) + assert.False(t, combi.Match(glfw.ModControl, glfw.KeyA)) + assert.False(t, combi.Match(glfw.ModAlt, glfw.KeyA)) + assert.False(t, combi.Match(0, glfw.KeyA)) + assert.False(t, combi.Match(glfw.ModControl^glfw.ModAlt^glfw.ModShift, glfw.KeyA)) + +} diff --git a/gui/actions.go b/gui/actions.go new file mode 100644 index 0000000..3539061 --- /dev/null +++ b/gui/actions.go @@ -0,0 +1,47 @@ +package gui + +import ( + "fmt" + "net/url" + + "github.com/liamg/aminal/config" +) + +var actionMap = map[config.UserAction]func(gui *GUI){ + config.ActionCopy: actionCopy, + config.ActionPaste: actionPaste, + config.ActionToggleDebug: actionToggleDebug, + config.ActionGoogle: actionGoogleSelection, + config.ActionToggleSlomo: actionToggleSlomo, + config.ActionReportBug: actionReportBug, +} + +func actionCopy(gui *GUI) { + gui.window.SetClipboardString(gui.terminal.ActiveBuffer().GetSelectedText()) +} + +func actionPaste(gui *GUI) { + if s, err := gui.window.GetClipboardString(); err == nil { + _ = gui.terminal.Paste([]byte(s)) + } +} + +func actionToggleDebug(gui *GUI) { + gui.showDebugInfo = !gui.showDebugInfo + gui.terminal.SetDirty() +} + +func actionGoogleSelection(gui *GUI) { + keywords := gui.terminal.ActiveBuffer().GetSelectedText() + if keywords != "" { + gui.launchTarget(fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(keywords))) + } +} + +func actionToggleSlomo(gui *GUI) { + gui.config.Slomo = !gui.config.Slomo +} + +func actionReportBug(gui *GUI) { + gui.launchTarget("https://github.com/liamg/aminal/issues/new/choose") +} diff --git a/gui/gui.go b/gui/gui.go index 4a4e5aa..21cf08e 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -15,33 +15,40 @@ import ( ) type GUI struct { - window *glfw.Window - logger *zap.SugaredLogger - config *config.Config - terminal *terminal.Terminal - width int //window width in pixels - height int //window height in pixels - fontMap *FontMap - fontScale float32 - renderer *OpenGLRenderer - colourAttr uint32 - mouseDown bool - overlay overlay - terminalAlpha float32 - showDebugInfo bool + window *glfw.Window + logger *zap.SugaredLogger + config *config.Config + terminal *terminal.Terminal + width int //window width in pixels + height int //window height in pixels + fontMap *FontMap + fontScale float32 + renderer *OpenGLRenderer + colourAttr uint32 + mouseDown bool + overlay overlay + terminalAlpha float32 + showDebugInfo bool + keyboardShortcuts map[config.UserAction]*config.KeyCombination } -func New(config *config.Config, terminal *terminal.Terminal, logger *zap.SugaredLogger) *GUI { +func New(config *config.Config, terminal *terminal.Terminal, logger *zap.SugaredLogger) (*GUI, error) { + + shortcuts, err := config.KeyMapping.GenerateActionMap() + if err != nil { + return nil, err + } return &GUI{ - config: config, - logger: logger, - width: 800, - height: 600, - terminal: terminal, - fontScale: 14.0, - terminalAlpha: 1, - } + config: config, + logger: logger, + width: 800, + height: 600, + terminal: terminal, + fontScale: 14.0, + terminalAlpha: 1, + keyboardShortcuts: shortcuts, + }, nil } // inspired by https://kylewbanks.com/blog/tutorial-opengl-with-golang-part-1-hello-opengl @@ -254,9 +261,7 @@ func (gui *GUI) Render() error { gui.renderOverlay() if gui.showDebugInfo { - gui.textbox(2, 2, fmt.Sprintf(`Debug Panel -=========== -Cursor: %d,%d + gui.textbox(2, 2, fmt.Sprintf(`Cursor: %d,%d View Size: %d,%d Buffer Size: %d lines `, @@ -266,8 +271,8 @@ Buffer Size: %d lines gui.terminal.ActiveBuffer().ViewHeight(), gui.terminal.ActiveBuffer().Height(), ), - [3]float32{0, 1, 0}, - [3]float32{0, 0, 0}, + [3]float32{1, 1, 1}, + [3]float32{0.8, 0, 0}, ) } diff --git a/gui/input.go b/gui/input.go index b03fcfc..d7d390b 100644 --- a/gui/input.go +++ b/gui/input.go @@ -2,7 +2,6 @@ package gui import ( "fmt" - "net/url" "github.com/go-gl/glfw/v3.2/glfw" ) @@ -32,10 +31,31 @@ func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Acti } } - gui.logger.Debugf("KEY PRESS: key=0x%X scan=0x%X", key, scancode) + for userAction, shortcut := range gui.keyboardShortcuts { + + if shortcut.Match(mods, key) { + + f, ok := actionMap[userAction] + if ok { + f(gui) + break + } + + switch key { + case glfw.KeyD: + + case glfw.KeyG: + + case glfw.KeyR: + gui.launchTarget("https://github.com/liamg/aminal/issues/new/choose") + case glfw.KeySemicolon: + gui.config.Slomo = !gui.config.Slomo + return + } + } + } modStr := "" - switch true { case modsPressed(mods, glfw.ModControl, glfw.ModShift, glfw.ModAlt): modStr = "8" @@ -43,29 +63,6 @@ func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Acti modStr = "7" case modsPressed(mods, glfw.ModControl, glfw.ModShift): modStr = "6" - switch key { - case glfw.KeyC: - gui.window.SetClipboardString(gui.terminal.ActiveBuffer().GetSelectedText()) - return - case glfw.KeyD: - gui.showDebugInfo = !gui.showDebugInfo - gui.terminal.SetDirty() - case glfw.KeyG: - keywords := gui.terminal.ActiveBuffer().GetSelectedText() - if keywords != "" { - gui.launchTarget(fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(keywords))) - } - case glfw.KeyR: - gui.launchTarget("https://github.com/liamg/aminal/issues/new/choose") - case glfw.KeyV: - if s, err := gui.window.GetClipboardString(); err == nil { - _ = gui.terminal.Paste([]byte(s)) - } - return - case glfw.KeySemicolon: - gui.config.Slomo = !gui.config.Slomo - return - } case modsPressed(mods, glfw.ModControl): modStr = "5" switch key { diff --git a/main.go b/main.go index b101720..d358485 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,10 @@ func main() { logger.Infof("Creating terminal...") terminal := terminal.New(pty, logger, conf) - g := gui.New(conf, terminal, logger) + g, err := gui.New(conf, terminal, logger) + if err != nil { + logger.Fatalf("Cannot start: %s", err) + } if err := g.Render(); err != nil { logger.Fatalf("Render error: %s", err) }