diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a145b..b9c4983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,28 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2024-01-21 + +### Added + +- add support for input source collections, allowing users to group and cycle through different sets of input sources +- introduce dynamic starting behavior, enabling the utility to begin with the currently active input source upon initialization + +### Changed + +- update the configuration structure to accommodate collections of input sources, replacing the previous primary and additional input sources configuration +- modify the Double Press Mode to switch between different input source collections +- adjust the Single Press Mode to cycle through input sources within the current collection +- move Homebrew tap and `betterglobekey.rb` formula to `Serpentiel/homebrew-tools` + +### Removed + +- remove the distinction between primary and additional input sources in the configuration, in favor of the new collection-based approach + +### Fixed + +- fix an issue where the utility would not start from the currently active input source + ## [2.1.1] - 2023-04-05 ### Changed @@ -113,7 +135,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - initial release -[unreleased]: https://github.com/Serpentiel/betterglobekey/compare/v2.1.1...HEAD +[unreleased]: https://github.com/Serpentiel/betterglobekey/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/Serpentiel/betterglobekey/releases/tag/v3.0.0 [2.1.1]: https://github.com/Serpentiel/betterglobekey/releases/tag/v2.1.1 [2.1.0]: https://github.com/Serpentiel/betterglobekey/releases/tag/v2.1.0 [2.0.1]: https://github.com/Serpentiel/betterglobekey/releases/tag/v2.0.1 diff --git a/README.md b/README.md index d348dbf..43e0527 100644 --- a/README.md +++ b/README.md @@ -77,32 +77,45 @@ experience, and I sincerely hope that one day Apple is going to make it this way ## Getting Started -The utility replaces the default behavior of the Globe key and adds two new modes to it: +The utility enhances the functionality of the Globe key by introducing two distinct modes of operation and starting +from the currently active input source: 1. **Single Press Mode** - Single press mode is the mode that is activated when the Globe key is pressed once. + Single press mode is activated when the Globe key is pressed once. - Single press mode cycles between your primary input sources—I believe most of the users out there will not even need - the other available mode as it is probably only useful if you have more than average amount of input sources. + In this mode, the utility cycles through a collection of input sources. Each press of the Globe key switches to the + next input source within the current collection. - Single press mode uses the input sources defined in the config's `input_sources.primary` array. + The collections of input sources are defined in the configuration under `input_sources`. Each key-value pair within + this map represents a named collection of input sources. For example: -2. **Double Press Mode** + ```yaml + input_sources: + foo: + - com.apple.keylayout.US + - com.apple.keylayout.Russian + bar: + - com.apple.keylayout.Finnish + - com.apple.keylayout.Ukrainian + - com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese + ``` + + Upon initialization, the utility determines the current active input source and starts from that particular source + within its respective collection. - Double press mode is the mode that is activated when the Globe key is double pressed. +2. **Double Press Mode** - Double press mode cycles between your additional input sources. If you use multiple input sources, you - probably use only several input sources frequently—you might consider putting those that you use the least under - additional input sources. + Double press mode is activated when the Globe key is double-pressed. - Double press mode uses the input sources defined in the config's `input_sources.additional` array. + In this mode, the utility switches between different collections of input sources. Each double press of the Globe + key cycles to the next collection in the configuration. - Double press maximum delay is also configurable in the config's `double_press.maximum_delay` property. + The maximum time interval between the first and second press that is considered a double press can be configured + in the `double_press.maximum_delay` property. This delay is specified in milliseconds. - > **N.B.** This is not working as designed at the moment—this is supposed to open the original input source popup, but - > implementing it requires some reverse engineering. There is probably a function in macOS private API that can be used - > to open the popup. +These enhancements aim to provide a more versatile and user-friendly experience for managing multiple input sources, +especially for users who frequently switch between different languages or keyboard layouts. ### Prerequisites @@ -115,7 +128,7 @@ The utility replaces the default behavior of the Globe key and adds two new mode - Install the utility via [Homebrew](https://brew.sh): ```bash - brew tap Serpentiel/betterglobekey https://github.com/Serpentiel/betterglobekey.git + brew tap Serpentiel/tools brew install betterglobekey ``` diff --git a/cmd/root.go b/cmd/root.go index 588d894..0b69d96 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,7 @@ import ( "context" "os" - "github.com/Serpentiel/betterglobekey/internal/eventhandler" + "github.com/Serpentiel/betterglobekey/internal/pkg/eventhandler" "github.com/Serpentiel/betterglobekey/internal/provide" "github.com/Serpentiel/betterglobekey/pkg/logger" hook "github.com/robotn/gohook" diff --git a/internal/assets/.betterglobekey.example.yaml b/internal/assets/.betterglobekey.example.yaml index a81aa1b..5dc0d7d 100644 --- a/internal/assets/.betterglobekey.example.yaml +++ b/internal/assets/.betterglobekey.example.yaml @@ -1,26 +1,32 @@ -# logger defines the parameters for the logger. +# Logger settings logger: - # path is the path to the log file. + # Path to the log file path: betterglobekey.log - - # retain defines the parameters for log file retention. + # Log file retention settings retain: - # days is the number of days to retain log files. + # Number of days to retain log files days: 30 - - # copies is the number of log files to retain. + # Number of log files to retain copies: 3 -# double_press defines double press configuration options. +# Configuration options for double press of the Globe key double_press: - # maximum_delay is the maximum time in milliseconds between the first and second press of the Globe key to be - # considered a double press. + # Maximum time (in milliseconds) between first and second press to consider as a double press maximum_delay: 250 -# input_sources defines the input sources to be used when the Globe key is pressed. -input_sources: - # primary defines the primary input sources. This is used when the Globe key has not been double pressed. - primary: [] - - # additional defines the additional input sources. This is used when the Globe key has been double pressed. - additional: [] +# Input sources configuration +# +# Each key-value pair within the 'input_sources' map represents a named collection of input sources. +# Single press of the Globe key cycles through input sources within the current collection. +# Double press of the Globe key switches between these collections. +# +# Example configuration: +# input_sources: +# foo: +# - com.apple.keylayout.US +# - com.apple.keylayout.Russian +# bar: +# - com.apple.keylayout.Finnish +# - com.apple.keylayout.Ukrainian +# - com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese +input_sources: {} diff --git a/internal/eventhandler/fnkeyhandler.go b/internal/eventhandler/fnkeyhandler.go deleted file mode 100644 index fbb6c0b..0000000 --- a/internal/eventhandler/fnkeyhandler.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package eventhandler encapsulates logic to handle keyboard events. -package eventhandler - -import ( - "time" - - "github.com/Serpentiel/betterglobekey/pkg/inputsource" - "github.com/Serpentiel/betterglobekey/pkg/logger" - "github.com/spf13/viper" -) - -// newFnKeyHandler returns a new fnKeyHandler. -func newFnKeyHandler(v *viper.Viper, l logger.Logger) *fnKeyHandler { - return &fnKeyHandler{ - l: l, - - doublePressMaximumDelay: v.GetInt("double_press.maximum_delay"), - primaryInputSources: v.GetStringSlice("input_sources.primary"), - additionalInputSources: v.GetStringSlice("input_sources.additional"), - } -} - -var _ handler = (*fnKeyHandler)(nil) - -// fnKeyHandler is a handler for the fn key up event. -type fnKeyHandler struct { - // l is the logger. - l logger.Logger - - // doublePressMaximumDelay is the maximum delay between two presses of the fn key for them to be considered as a - // double press. - doublePressMaximumDelay int - - // primaryInputSources is a slice of the primary input sources. - primaryInputSources []string - - // additionalInputSources is a slice of the additional input sources. - additionalInputSources []string - - // doublePressable is a bool that indicates if the key is double pressable. - doublePressable bool - - // doublePressed is a bool that indicates if the key is double pressed. - doublePressed bool - - // currentInputSource is the current input source. - currentInputSource string - - // previousInputSource is the previous input source. - previousInputSource string -} - -// getNextInputSource returns the next input source. -func (h *fnKeyHandler) getNextInputSource(inputSources *[]string) int { - nextInputSource := 0 - - for k, v := range *inputSources { - if v == h.currentInputSource { - if k == len(*inputSources)-1 { - break - } - - nextInputSource = k + 1 - } - } - - return nextInputSource -} - -// setInputSource sets the input source. -func (h *fnKeyHandler) setInputSource(inputSource string) { - h.previousInputSource = h.currentInputSource - - inputsource.Select(inputSource) - - h.l.Info("input source set", "from", h.previousInputSource, "to", inputSource) -} - -// KeyUp is called when the key is released. -func (h *fnKeyHandler) KeyUp() handlerFunc { - return func() { - if len(h.additionalInputSources) > 0 { - doublePressTicker := time.NewTicker(time.Duration(h.doublePressMaximumDelay) * time.Millisecond) - - h.doublePressed = h.doublePressable - - h.doublePressable = !h.doublePressable - - go func() { - if <-doublePressTicker.C; true { - h.doublePressable = false - - doublePressTicker.Stop() - - return - } - }() - } - - h.currentInputSource = inputsource.Current() - - if !h.doublePressed { - h.l.Info("globe key pressed") - - h.setInputSource(h.primaryInputSources[h.getNextInputSource(&h.primaryInputSources)]) - } else { - // TODO: This is not working as designed at the moment—this is supposed to open the original - // input source popup, but implementing it requires some reverse engineering. - // There is probably a function in macOS private API that can be used to open the popup. - h.l.Info("globe key double pressed") - - h.setInputSource(h.previousInputSource) - - h.currentInputSource = inputsource.Current() - - h.setInputSource(h.additionalInputSources[h.getNextInputSource(&h.additionalInputSources)]) - } - } -} diff --git a/internal/eventhandler/eventhandler.go b/internal/pkg/eventhandler/eventhandler.go similarity index 100% rename from internal/eventhandler/eventhandler.go rename to internal/pkg/eventhandler/eventhandler.go diff --git a/internal/pkg/eventhandler/fnkeyhandler.go b/internal/pkg/eventhandler/fnkeyhandler.go new file mode 100644 index 0000000..37eb648 --- /dev/null +++ b/internal/pkg/eventhandler/fnkeyhandler.go @@ -0,0 +1,202 @@ +package eventhandler + +import ( + "errors" + "time" + + "github.com/Serpentiel/betterglobekey/pkg/inputsource" + "github.com/Serpentiel/betterglobekey/pkg/logger" + "github.com/Serpentiel/betterglobekey/pkg/util" + "github.com/spf13/viper" +) + +// errNoInputSourceAvailable is the error returned when no input source is available. +var errNoInputSourceAvailable = errors.New("no input source available") + +// newFnKeyHandler initializes and returns a new fnKeyHandler instance. +func newFnKeyHandler(v *viper.Viper, l logger.Logger) *fnKeyHandler { + inputSources := v.GetStringMapStringSlice("input_sources") + + currentInputSource := inputsource.Current() + + var currentCollection string + + for collection, sources := range inputSources { + if util.Contains(sources, currentInputSource) { + currentCollection = collection + + break + } + } + + if currentCollection == "" && len(inputSources) > 0 { + for k := range inputSources { + currentCollection = k + + break + } + } + + return &fnKeyHandler{ + l: l, + doublePressMaximumDelay: v.GetInt("double_press.maximum_delay"), + inputSources: inputSources, + + lastInputSourceInCollection: make(map[string]string), + currentCollection: currentCollection, + currentInputSource: currentInputSource, + lastPressTime: time.Now(), + } +} + +// fnKeyHandler is a type that represents a handler for the fn key. +type fnKeyHandler struct { + // l is the logger. + l logger.Logger + // doublePressMaximumDelay is the maximum delay between two presses to be considered a double press. + doublePressMaximumDelay int + // inputSources is a map of input source collections. + inputSources map[string][]string + + // lastInputSourceInCollection is a map of the last input source used in each collection. + lastInputSourceInCollection map[string]string + // currentCollection is the current input source collection. + currentCollection string + // currentInputSource is the current input source. + currentInputSource string + // lastPressTime is the time of the last press. + lastPressTime time.Time +} + +// KeyUp handles key up events and determines whether it's a single or double press. +func (h *fnKeyHandler) KeyUp() handlerFunc { + return func() { + currentTime := time.Now() + elapsed := currentTime.Sub(h.lastPressTime) + h.lastPressTime = currentTime + + if elapsed <= time.Duration(h.doublePressMaximumDelay)*time.Millisecond { + h.handleDoublePress() + } else { + h.handleSinglePress() + } + } +} + +// handleSinglePress handles a single press of the fn key. +func (h *fnKeyHandler) handleSinglePress() { + h.l.Info("globe key pressed") + + nextSource, err := h.getNextInputSource() + if err != nil { + h.l.Error("failed to get next input source", "error", err) + + return + } + + h.lastInputSourceInCollection[h.currentCollection] = nextSource + h.setInputSource(nextSource) +} + +// handleDoublePress handles a double press of the fn key. +func (h *fnKeyHandler) handleDoublePress() { + h.l.Info("globe key double pressed") + + h.decrementPreviousInputSource(h.currentCollection) + + h.switchCollection() + h.setInputSourceToLastOrFirst() +} + +// getNextInputSource returns the next input source within the current collection. +func (h *fnKeyHandler) getNextInputSource() (string, error) { + collection, exists := h.inputSources[h.currentCollection] + if !exists || len(collection) == 0 { + return "", errNoInputSourceAvailable + } + + for i, source := range collection { + if source == h.currentInputSource { + return collection[(i+1)%len(collection)], nil + } + } + + return collection[0], nil +} + +// switchCollection cycles to the next input source collection. +func (h *fnKeyHandler) switchCollection() { + var nextCollection string + + foundCurrent := false + + for name := range h.inputSources { + if foundCurrent { + nextCollection = name + + break + } + + if name == h.currentCollection { + foundCurrent = true + } + } + + if nextCollection == "" { + for name := range h.inputSources { + nextCollection = name + + break + } + } + + h.currentCollection = nextCollection +} + +// setInputSourceToLastOrFirst sets the input source to the last used in the current collection or the first one. +func (h *fnKeyHandler) setInputSourceToLastOrFirst() { + nextSource := h.lastInputSourceInCollection[h.currentCollection] + + if nextSource == "" { + nextSource = h.inputSources[h.currentCollection][0] + } + + if nextSource != "" { + h.setInputSource(nextSource) + } +} + +// decrementPreviousInputSource decrements the last input source in the specified collection. +func (h *fnKeyHandler) decrementPreviousInputSource(collection string) { + lastSource := h.lastInputSourceInCollection[collection] + collectionSources := h.inputSources[collection] + + index := -1 + + for i, source := range collectionSources { + if source == lastSource { + index = i + + break + } + } + + if index > 0 { + h.lastInputSourceInCollection[collection] = collectionSources[index-1] + } else if index == 0 { + h.lastInputSourceInCollection[collection] = collectionSources[len(collectionSources)-1] + } +} + +// setInputSource sets the input source if it exists. +func (h *fnKeyHandler) setInputSource(inputSource string) { + h.currentInputSource = inputSource + + if util.Contains(inputsource.All(), inputSource) { + inputsource.Select(inputSource) + + h.l.Info("input source set", "source", inputSource) + } else { + h.l.Info("input source not found", "source", inputSource) + } +} diff --git a/internal/eventhandler/rawcode.go b/internal/pkg/eventhandler/rawcode.go similarity index 100% rename from internal/eventhandler/rawcode.go rename to internal/pkg/eventhandler/rawcode.go diff --git a/pkg/util/slices.go b/pkg/util/slices.go new file mode 100644 index 0000000..d3ebb94 --- /dev/null +++ b/pkg/util/slices.go @@ -0,0 +1,13 @@ +// Package util contains utility functions. +package util + +// Contains checks if a slice contains a given string. +func Contains(slice []string, str string) bool { + for _, v := range slice { + if v == str { + return true + } + } + + return false +}