From 0915ea059a641a202abf52d2fb5bb0ba9d10cad8 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 15 Jul 2025 00:46:07 -0400 Subject: [PATCH] (WIP) Implement axis-to-relaxis repeats; similar to buttons but for discretized relative axis inputs. (i.e. mousewheel) --- cmd/joyful/main.go | 2 +- cmd/joyful/threads.go | 6 +- internal/config/make_rule_targets.go | 19 ++++ internal/config/make_rules.go | 24 +++++ internal/config/schema.go | 11 ++- internal/config/variables.go | 13 +-- internal/mappingrules/interfaces.go | 12 ++- .../mapping_rule_axis_to_button.go | 11 ++- .../mapping_rule_axis_to_relaxis.go | 98 +++++++++++++++++++ internal/mappingrules/rule_target_relaxis.go | 46 +++++++++ 10 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 internal/mappingrules/mapping_rule_axis_to_relaxis.go create mode 100644 internal/mappingrules/rule_target_relaxis.go diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 9ce1d66..79495ef 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -75,7 +75,7 @@ func main() { timerCount := 0 for _, rule := range rules { - if timedRule, ok := rule.(*mappingrules.MappingRuleAxisToButton); ok { + if timedRule, ok := rule.(mappingrules.TimedEventEmitter); ok { go timerWatcher(timedRule, eventChannel) timerCount++ } diff --git a/cmd/joyful/threads.go b/cmd/joyful/threads.go index 9f28101..e630881 100644 --- a/cmd/joyful/threads.go +++ b/cmd/joyful/threads.go @@ -9,7 +9,7 @@ import ( ) const ( - TimerCheckIntervalMs = 250 + TimerCheckIntervalMs = 1 DeviceCheckIntervalMs = 1 ) @@ -28,12 +28,12 @@ func eventWatcher(device *evdev.InputDevice, channel chan<- ChannelEvent) { } } -func timerWatcher(rule *mappingrules.MappingRuleAxisToButton, channel chan<- ChannelEvent) { +func timerWatcher(rule mappingrules.TimedEventEmitter, channel chan<- ChannelEvent) { for { event := rule.TimerEvent() if event != nil { channel <- ChannelEvent{ - Device: rule.Output.Device, + Device: rule.GetOutputDevice(), Event: event, Type: ChannelEventTimer, } diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go index 28afedc..cc6c458 100644 --- a/internal/config/make_rule_targets.go +++ b/internal/config/make_rule_targets.go @@ -48,6 +48,25 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.In ) } +func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetRelaxis, error) { + device, ok := devs[targetConfig.Device] + if !ok { + return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) + } + + eventCode, ok := evdev.RELFromString[targetConfig.Axis] + if !ok { + return nil, fmt.Errorf("invalid button code '%s'", targetConfig.Button) + } + + return mappingrules.NewRuleTargetRelaxis( + targetConfig.Device, + device, + eventCode, + targetConfig.Inverted, + ) +} + func makeRuleTargetModeSelect(targetConfig RuleTargetConfig, allModes []string) (*mappingrules.RuleTargetModeSelect, error) { if ok := validateModes(targetConfig.Modes, allModes); !ok { return nil, errors.New("undefined mode in mode select list") diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go index 1b6490c..6d75d58 100644 --- a/internal/config/make_rules.go +++ b/internal/config/make_rules.go @@ -40,6 +40,8 @@ func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDev newRule, err = makeMappingRuleAxis(ruleConfig, pDevs, vDevs, base) case RuleTypeAxisToButton: newRule, err = makeMappingRuleAxisToButton(ruleConfig, pDevs, vDevs, base) + case RuleTypeAxisToRelaxis: + newRule, err = makeMappingRuleAxisToRelaxis(ruleConfig, pDevs, vDevs, base) case RuleTypeModeSelect: newRule, err = makeMappingRuleModeSelect(ruleConfig, pDevs, modes, base) default: @@ -151,6 +153,28 @@ func makeMappingRuleAxisToButton(ruleConfig RuleConfig, return mappingrules.NewMappingRuleAxisToButton(base, input, output, ruleConfig.RepeatRateMin, ruleConfig.RepeatRateMax), nil } +func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig, + pDevs map[string]*evdev.InputDevice, + vDevs map[string]*evdev.InputDevice, + base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToRelaxis, error) { + + input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := makeRuleTargetRelaxis(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } + + return mappingrules.NewMappingRuleAxisToRelaxis(base, + input, output, + ruleConfig.RepeatRateMin, + ruleConfig.RepeatRateMax, + ruleConfig.Increment), nil +} + func makeMappingRuleModeSelect(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, modes []string, diff --git a/internal/config/schema.go b/internal/config/schema.go index 1482c8c..b91bb8d 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1,5 +1,11 @@ // These types comprise the YAML schema for configuring Joyful. // The config files will be combined and then unmarshalled into this +// +// TODO: currently the types in here aren't especially strong; each one is +// decomposed into a different object based on the Type fields. We should implement +// some sort of delayed unmarshalling technique, for example see ideas at +// https://stackoverflow.com/questions/70635636/unmarshaling-yaml-into-different-struct-based-off-yaml-field +// Then we can be more explicit about the interface here. package config @@ -27,14 +33,15 @@ type RuleConfig struct { Modes []string `yaml:"modes,omitempty"` RepeatRateMin int `yaml:"repeat_rate_min,omitempty"` RepeatRateMax int `yaml:"repeat_rate_max,omitempty"` + Increment int `yaml:"increment,omitempty"` } type RuleTargetConfig struct { Device string `yaml:"device,omitempty"` Button string `yaml:"button,omitempty"` Axis string `yaml:"axis,omitempty"` - DeadzoneStart int32 `yaml:"axis_start,omitempty"` - DeadzoneEnd int32 `yaml:"axis_end,omitempty"` + DeadzoneStart int32 `yaml:"deadzone_start,omitempty"` + DeadzoneEnd int32 `yaml:"deadzone_end,omitempty"` Inverted bool `yaml:"inverted,omitempty"` Modes []string `yaml:"modes,omitempty"` } diff --git a/internal/config/variables.go b/internal/config/variables.go index f352474..c0276c4 100644 --- a/internal/config/variables.go +++ b/internal/config/variables.go @@ -8,12 +8,13 @@ const ( DeviceTypePhysical = "physical" DeviceTypeVirtual = "virtual" - RuleTypeButton = "button" - RuleTypeButtonCombo = "button-combo" - RuleTypeLatched = "button-latched" - RuleTypeAxis = "axis" - RuleTypeModeSelect = "mode-select" - RuleTypeAxisToButton = "axis-to-button" + RuleTypeButton = "button" + RuleTypeButtonCombo = "button-combo" + RuleTypeLatched = "button-latched" + RuleTypeAxis = "axis" + RuleTypeModeSelect = "mode-select" + RuleTypeAxisToButton = "axis-to-button" + RuleTypeAxisToRelaxis = "axis-to-relaxis" ) var ( diff --git a/internal/mappingrules/interfaces.go b/internal/mappingrules/interfaces.go index 3be58b4..bc10e9b 100644 --- a/internal/mappingrules/interfaces.go +++ b/internal/mappingrules/interfaces.go @@ -1,11 +1,20 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "time" + + "github.com/holoplot/go-evdev" +) type MappingRule interface { MatchEvent(RuleTargetDevice, *evdev.InputEvent, *string) (*evdev.InputDevice, *evdev.InputEvent) } +type TimedEventEmitter interface { + TimerEvent() *evdev.InputEvent + GetOutputDevice() *evdev.InputDevice +} + // RuleTargets represent either a device input to match on, or an output to produce. // Some RuleTarget types may work via side effects, such as RuleTargetModeSelect. type RuleTarget interface { @@ -39,4 +48,5 @@ type RuleTargetDevice interface { const ( AxisValueMin = int32(-32768) AxisValueMax = int32(32767) + NoNextEvent = time.Duration(-1) ) diff --git a/internal/mappingrules/mapping_rule_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index 0873913..9c4205a 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -19,10 +19,6 @@ type MappingRuleAxisToButton struct { pressed bool } -const ( - NoNextEvent = time.Duration(-1) -) - func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, output *RuleTargetButton, repeatRateMin, repeatRateMax int) *MappingRuleAxisToButton { return &MappingRuleAxisToButton{ MappingRuleBase: base, @@ -50,6 +46,7 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * } // If we aren't repeating, we trigger the event immediately + // TODO: we aren't using pressed correctly; that should be set *and released* in here... if rule.RepeatRateMin == 0 || rule.RepeatRateMax == 0 { rule.nextEvent = time.Millisecond return nil, nil @@ -73,7 +70,7 @@ func (rule *MappingRuleAxisToButton) TimerEvent() *evdev.InputEvent { } // This indicates that we should not emit another event - if rule.nextEvent == -1 { + if rule.nextEvent == NoNextEvent { rule.lastEvent = time.Now() return nil } @@ -86,3 +83,7 @@ func (rule *MappingRuleAxisToButton) TimerEvent() *evdev.InputEvent { return nil } + +func (rule *MappingRuleAxisToButton) GetOutputDevice() *evdev.InputDevice { + return rule.Output.Device +} diff --git a/internal/mappingrules/mapping_rule_axis_to_relaxis.go b/internal/mappingrules/mapping_rule_axis_to_relaxis.go new file mode 100644 index 0000000..07e4b85 --- /dev/null +++ b/internal/mappingrules/mapping_rule_axis_to_relaxis.go @@ -0,0 +1,98 @@ +package mappingrules + +import ( + "time" + + "git.annabunches.net/annabunches/joyful/internal/logger" + "github.com/holoplot/go-evdev" +) + +// TODO: add tests + +// TODO: deadzones seem to calculate correctly in one direction but not the other when computing axis strength... + +// MappingRuleAxisToRelaxis represents a rule that converts an axis input into a (potentially repeating) +// button output. +type MappingRuleAxisToRelaxis struct { + MappingRuleBase + Input *RuleTargetAxis + Output *RuleTargetRelaxis + RepeatRateMin int + RepeatRateMax int + Increment int32 + nextEvent time.Duration + lastEvent time.Time +} + +func NewMappingRuleAxisToRelaxis( + base MappingRuleBase, + input *RuleTargetAxis, + output *RuleTargetRelaxis, + repeatRateMin, repeatRateMax, increment int) *MappingRuleAxisToRelaxis { + + return &MappingRuleAxisToRelaxis{ + MappingRuleBase: base, + Input: input, + Output: output, + RepeatRateMin: repeatRateMin, + RepeatRateMax: repeatRateMax, + Increment: int32(increment), + lastEvent: time.Now(), + nextEvent: NoNextEvent, + } +} + +func (rule *MappingRuleAxisToRelaxis) MatchEvent( + device RuleTargetDevice, + event *evdev.InputEvent, + mode *string) (*evdev.InputDevice, *evdev.InputEvent) { + + if !rule.MappingRuleBase.modeCheck(mode) || + !rule.Input.MatchEventDeviceAndCode(device, event) { + return nil, nil + } + + defer func() { + logger.Logf("DEBUG: Rule '%s' nextEvent == '%v' with device value '%d'", rule.Name, rule.nextEvent, event.Value) + }() + + // If we're inside the deadzone, unset the next event + if rule.Input.InDeadZone(event.Value) { + rule.nextEvent = NoNextEvent + return nil, nil + } + + // If we aren't repeating, we trigger the event immediately + // TODO: this still needs the pressed parameter... + if rule.RepeatRateMin == 0 || rule.RepeatRateMax == 0 { + rule.nextEvent = time.Millisecond + return nil, nil + } + + // use the axis value and the repeat rate to set a target time until the next event + strength := 1.0 - rule.Input.GetAxisStrength(event.Value) + rate := int64(LerpInt(rule.RepeatRateMax, rule.RepeatRateMin, strength)) + rule.nextEvent = time.Duration(rate * int64(time.Millisecond)) + return nil, nil +} + +// TimerEvent returns an event when enough time has passed (compared to the last recorded axis value) +// to emit an event. +func (rule *MappingRuleAxisToRelaxis) TimerEvent() *evdev.InputEvent { + // This indicates that we should not emit another event + if rule.nextEvent == NoNextEvent { + rule.lastEvent = time.Now() + return nil + } + + if time.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = time.Now() + return rule.Output.CreateEvent(rule.Increment, nil) + } + + return nil +} + +func (rule *MappingRuleAxisToRelaxis) GetOutputDevice() *evdev.InputDevice { + return rule.Output.Device.(*evdev.InputDevice) +} diff --git a/internal/mappingrules/rule_target_relaxis.go b/internal/mappingrules/rule_target_relaxis.go new file mode 100644 index 0000000..648d7fc --- /dev/null +++ b/internal/mappingrules/rule_target_relaxis.go @@ -0,0 +1,46 @@ +package mappingrules + +import ( + "github.com/holoplot/go-evdev" +) + +type RuleTargetRelaxis struct { + DeviceName string + Device RuleTargetDevice + Axis evdev.EvCode + Inverted bool +} + +func NewRuleTargetRelaxis(device_name string, + device RuleTargetDevice, + axis evdev.EvCode, + inverted bool) (*RuleTargetRelaxis, error) { + + return &RuleTargetRelaxis{ + DeviceName: device_name, + Device: device, + Axis: axis, + Inverted: inverted, + }, nil +} + +// NormalizeValue takes a raw input value and converts it to a value suitable for output. +// +// Relative axes are currently only supported for output. +// TODO: make this have an error return? +func (target *RuleTargetRelaxis) NormalizeValue(value int32) int32 { + return 0 +} + +func (target *RuleTargetRelaxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { + return &evdev.InputEvent{ + Type: evdev.EV_REL, + Code: target.Axis, + Value: value, + } +} + +// Relative axis is only supported for output. +func (target *RuleTargetRelaxis) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent) bool { + return false +}