Merge pull request 'Refactor rule target functions into methods.' (#1) from refactor-oo into main

Reviewed-on: annabunches/joyful#1
This commit is contained in:
Anna Rose 2025-07-04 16:51:34 +00:00
commit acba227843
7 changed files with 182 additions and 83 deletions

View file

@ -32,7 +32,11 @@ func timerWatcher(rule *mappingrules.ProportionalAxisMappingRule, channel chan<-
for { for {
event := rule.TimerEvent() event := rule.TimerEvent()
if event != nil { if event != nil {
channel <- ChannelEvent{Device: rule.Output.Device, Event: event, Type: ChannelEventTimer} channel <- ChannelEvent{
Device: rule.Output.(*mappingrules.RuleTargetModeSelect).Device,
Event: event,
Type: ChannelEventTimer,
}
} }
time.Sleep(TimerCheckIntervalMs * time.Millisecond) time.Sleep(TimerCheckIntervalMs * time.Millisecond)
} }

View file

@ -107,29 +107,45 @@ func makeLatchedRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice,
// makeInputRuleTarget takes an Input declaration from the YAML and returns a fully formed RuleTarget. // makeInputRuleTarget takes an Input declaration from the YAML and returns a fully formed RuleTarget.
func makeRuleTarget(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (mappingrules.RuleTarget, error) { func makeRuleTarget(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (mappingrules.RuleTarget, error) {
ruleTarget := mappingrules.RuleTarget{}
if len(targetConfig.ModeSelect) > 0 { if len(targetConfig.ModeSelect) > 0 {
ruleTarget.ModeSelect = targetConfig.ModeSelect return &mappingrules.RuleTargetModeSelect{
return ruleTarget, nil ModeSelect: targetConfig.ModeSelect,
}, nil
} }
device, ok := devs[targetConfig.Device] device, ok := devs[targetConfig.Device]
if !ok { if !ok {
return mappingrules.RuleTarget{}, fmt.Errorf("couldn't build rule due to non-existent device '%s'", targetConfig.Device) return nil, fmt.Errorf("couldn't build rule due to non-existent device '%s'", targetConfig.Device)
} }
ruleTarget.Device = device
eventType, eventCode, err := decodeRuleTargetValues(targetConfig) eventType, eventCode, err := decodeRuleTargetValues(targetConfig)
if err != nil { if err != nil {
return ruleTarget, err return nil, err
} }
ruleTarget.Type = eventType
ruleTarget.Code = eventCode
ruleTarget.Inverted = targetConfig.Inverted
ruleTarget.DeviceName = targetConfig.Device
return ruleTarget, nil baseParams := mappingrules.RuleTargetBase{
DeviceName: targetConfig.Device,
Device: device,
Inverted: targetConfig.Inverted,
Code: eventCode,
}
switch eventType {
case evdev.EV_KEY:
return &mappingrules.RuleTargetButton{
RuleTargetBase: baseParams,
}, nil
case evdev.EV_ABS:
return &mappingrules.RuleTargetAxis{
RuleTargetBase: baseParams,
AxisStart: targetConfig.AxisStart,
AxisEnd: targetConfig.AxisEnd,
}, nil
default:
return nil, fmt.Errorf("skipping rule due to unsupported event type '%d'", eventType)
}
} }
// decodeRuleTargetValues returns the appropriate evdev.EvType and evdev.EvCode values // decodeRuleTargetValues returns the appropriate evdev.EvType and evdev.EvCode values

View file

@ -31,6 +31,8 @@ type RuleTargetConfig struct {
Device string `yaml:"device,omitempty"` Device string `yaml:"device,omitempty"`
Button string `yaml:"button,omitempty"` Button string `yaml:"button,omitempty"`
Axis string `yaml:"axis,omitempty"` Axis string `yaml:"axis,omitempty"`
AxisStart int32 `yaml:"axis_start,omitempty"`
AxisEnd int32 `yaml:"axis_end,omitempty"`
Inverted bool `yaml:"inverted,omitempty"` Inverted bool `yaml:"inverted,omitempty"`
ModeSelect []string `yaml:"mode_select,omitempty"` ModeSelect []string `yaml:"mode_select,omitempty"`
} }

View file

@ -3,12 +3,11 @@ package mappingrules
import ( import (
"slices" "slices"
"git.annabunches.net/annabunches/joyful/internal/logger"
"github.com/holoplot/go-evdev" "github.com/holoplot/go-evdev"
) )
func (rule *MappingRuleBase) OutputName() string { func (rule *MappingRuleBase) OutputName() string {
return rule.Output.DeviceName return rule.Output.GetDeviceName()
} }
func (rule *MappingRuleBase) modeCheck(mode *string) bool { func (rule *MappingRuleBase) modeCheck(mode *string) bool {
@ -18,64 +17,17 @@ func (rule *MappingRuleBase) modeCheck(mode *string) bool {
return slices.Contains(rule.Modes, *mode) return slices.Contains(rule.Modes, *mode)
} }
// eventFromTarget creates an outputtable event from a RuleTarget
func eventFromTarget(output RuleTarget, value int32, mode *string) *evdev.InputEvent {
// TODO: this could perhaps use some sort of multiclassing... then again, maybe this is fine?
if len(output.ModeSelect) > 0 {
if value == 0 {
return nil
}
index := 0
if currentMode := slices.Index(output.ModeSelect, *mode); currentMode != -1 {
// find the next mode
index = (currentMode + 1) % len(output.ModeSelect)
}
*mode = output.ModeSelect[index]
logger.Logf("Mode changed to '%s'", *mode)
return nil
}
return &evdev.InputEvent{
Type: output.Type,
Code: output.Code,
Value: value,
}
}
// valueFromTarget determines the value to output from an input specification,given a RuleTarget's constraints
func valueFromTarget(rule RuleTarget, event *evdev.InputEvent) int32 {
// how we process inverted rules depends on the event type
value := event.Value
if rule.Inverted {
switch rule.Type {
case evdev.EV_KEY:
if value == 0 {
value = 1
} else {
value = 0
}
case evdev.EV_ABS:
logger.Logf("STUB: Inverting axes is not yet implemented.")
default:
logger.Logf("Inverted rule for unknown event type '%d'. Not inverting value", event.Type)
}
}
return value
}
func (rule *SimpleMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent { func (rule *SimpleMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent {
if !rule.MappingRuleBase.modeCheck(mode) { if !rule.MappingRuleBase.modeCheck(mode) {
return nil return nil
} }
if device != rule.Input.Device || if device != rule.Input.GetDevice() ||
event.Code != rule.Input.Code { event.Code != rule.Input.GetCode() {
return nil return nil
} }
return eventFromTarget(rule.Output, valueFromTarget(rule.Input, event), mode) return rule.Output.CreateEvent(rule.Input.NormalizeValue(event.Value), mode)
} }
func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent { func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent {
@ -84,11 +36,11 @@ func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev
} }
// Check each of the inputs, and if we find a match, proceed // Check each of the inputs, and if we find a match, proceed
var match *RuleTarget var match RuleTarget
for _, input := range rule.Inputs { for _, input := range rule.Inputs {
if device == input.Device && if device == input.GetDevice() &&
event.Code == input.Code { event.Code == input.GetCode() {
match = &input match = input
} }
} }
@ -97,7 +49,7 @@ func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev
} }
// Get the value and add/subtract it from State // Get the value and add/subtract it from State
inputValue := valueFromTarget(*match, event) inputValue := match.NormalizeValue(event.Value)
oldState := rule.State oldState := rule.State
if inputValue == 0 { if inputValue == 0 {
rule.State = max(rule.State-1, 0) rule.State = max(rule.State-1, 0)
@ -108,10 +60,10 @@ func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev
targetState := len(rule.Inputs) targetState := len(rule.Inputs)
if oldState == targetState-1 && rule.State == targetState { if oldState == targetState-1 && rule.State == targetState {
return eventFromTarget(rule.Output, 1, mode) return rule.Output.CreateEvent(1, mode)
} }
if oldState == targetState && rule.State == targetState-1 { if oldState == targetState && rule.State == targetState-1 {
return eventFromTarget(rule.Output, 0, mode) return rule.Output.CreateEvent(0, mode)
} }
return nil return nil
} }
@ -121,9 +73,9 @@ func (rule *LatchedMappingRule) MatchEvent(device *evdev.InputDevice, event *evd
return nil return nil
} }
if device != rule.Input.Device || if device != rule.Input.GetDevice() ||
event.Code != rule.Input.Code || event.Code != rule.Input.GetCode() ||
valueFromTarget(rule.Input, event) == 0 { rule.Input.NormalizeValue(event.Value) == 0 {
return nil return nil
} }
@ -136,7 +88,7 @@ func (rule *LatchedMappingRule) MatchEvent(device *evdev.InputDevice, event *evd
value = 0 value = 0
} }
return eventFromTarget(rule.Output, value, mode) return rule.Output.CreateEvent(value, mode)
} }
func (rule *ProportionalAxisMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent { func (rule *ProportionalAxisMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent {

View file

@ -0,0 +1,87 @@
package mappingrules
import (
"slices"
"git.annabunches.net/annabunches/joyful/internal/logger"
"github.com/holoplot/go-evdev"
)
func (target *RuleTargetBase) GetCode() evdev.EvCode {
return target.Code
}
func (target *RuleTargetBase) GetDeviceName() string {
return target.DeviceName
}
func (target *RuleTargetBase) GetDevice() *evdev.InputDevice {
return target.Device
}
func (target *RuleTargetButton) NormalizeValue(value int32) int32 {
if value == 0 {
return 1
}
return 0
}
func (target *RuleTargetButton) CreateEvent(value int32, mode *string) *evdev.InputEvent {
return &evdev.InputEvent{
Type: evdev.EV_KEY,
Code: target.Code,
Value: value,
}
}
func (target *RuleTargetAxis) NormalizeValue(value int32) int32 {
if !target.Inverted {
return value
}
axisRange := target.AxisEnd - target.AxisStart
axisMid := target.AxisEnd - axisRange/2
delta := value - axisMid
if delta < 0 {
delta = -delta
}
if value < axisMid {
return axisMid + delta
} else if value > axisMid {
return axisMid - delta
}
// If we reach here, we're either exactly at the midpoint or something
// strange has happened. Either way, just return the value.
return value
}
func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent {
return &evdev.InputEvent{
Type: evdev.EV_ABS,
Code: target.Code,
Value: value,
}
}
// RuleTargetModeSelect doesn't make sense as an input type
func (target *RuleTargetModeSelect) NormalizeValue(value int32) int32 {
return -1
}
func (target *RuleTargetModeSelect) CreateEvent(value int32, mode *string) *evdev.InputEvent {
if value == 0 {
return nil
}
index := 0
if currentMode := slices.Index(target.ModeSelect, *mode); currentMode != -1 {
// find the next mode
index = (currentMode + 1) % len(target.ModeSelect)
}
*mode = target.ModeSelect[index]
logger.Logf("Mode changed to '%s'", *mode)
return nil
}

View file

@ -44,11 +44,45 @@ type ProportionalAxisMappingRule struct {
LastEvent time.Time LastEvent time.Time
} }
type RuleTarget struct { // 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 {
// NormalizeValue takes the raw input value and possibly modifies it based on the Target settings.
// (e.g., inverting the value if Inverted == true)
NormalizeValue(int32) int32
// CreateEvent typically takes the (probably normalized) value and returns an event that can be emitted
// on a virtual device.
//
// For RuleTargetModeSelect, this method modifies the active mode and returns nil.
//
// TODO: should we normalize inside this function to simplify the interface?
CreateEvent(int32, *string) *evdev.InputEvent
GetCode() evdev.EvCode
GetDeviceName() string
GetDevice() *evdev.InputDevice
}
type RuleTargetBase struct {
DeviceName string DeviceName string
ModeSelect []string
Device *evdev.InputDevice Device *evdev.InputDevice
Type evdev.EvType
Code evdev.EvCode Code evdev.EvCode
Inverted bool Inverted bool
} }
type RuleTargetButton struct {
RuleTargetBase
}
type RuleTargetAxis struct {
RuleTargetBase
AxisStart int32
AxisEnd int32
Sensitivity float64
}
type RuleTargetModeSelect struct {
RuleTargetBase
ModeSelect []string
}

View file

@ -20,22 +20,23 @@ Joyful might be the tool for you.
* Create virtual devices with up to 8 axes and 80 buttons. * Create virtual devices with up to 8 axes and 80 buttons.
* Make simple 1:1 mappings of buttons and axes: Button1 -> VirtualButtonA * Make simple 1:1 mappings of buttons and axes: Button1 -> VirtualButtonA
* Make combination mappings: Button1 + Button2 -> VirtualButtonA * Make combination mappings: Button1 + Button2 -> VirtualButtonA
* Multiple modes with per-mode behavior.
### Future Features - try them at an unspecified point in the future! ### Future Features - try them at an unspecified point in the future!
* Multiple modes with per-mode behavior.
* Partial axis mapping: map sections of an axis to different outputs. * Partial axis mapping: map sections of an axis to different outputs.
* Highly configurable deadzones * Highly configurable deadzones
* Macros - have a single input produce a sequence of button presses with configurable pauses. * Macros - have a single input produce a sequence of button presses with configurable pauses.
* Sequence combos - Button1, Button2, Button3 -> VirtualButtonA * Sequence combos - Button1, Button2, Button3 -> VirtualButtonA
* Proportional axis to button mapping; repeatedly trigger a button with an axis, with frequency controlled by the axis value
## Configuration ## Configuration
Configuration is currently done via hand-written YAML files in `~/.config/joyful/`. Joyful will read every Configuration is currently done via hand-written YAML files in `~/.config/joyful/`. Joyful will read every
yaml file in this directory and combine them, so you can split your configuration up however you like. yaml file in this directory and combine them, so you can split your configuration up however you like.
Configuration is divided into two sections: `devices` and `rules`. Each of these is a YAML list. Configuration is divided into three sections: `devices`, `modes`, and `rules`. See the `examples/` directory for concrete examples.
The options for each are described in some detail below. See the `examples/` directory for concrete examples. Select options are explained in detail below.
### Device configuration ### Device configuration
@ -65,7 +66,10 @@ Configuration options for each type vary. See <examples/ruletypes.yml> for an ex
### Modes ### Modes
All rules can have a `modes` field that is a list of strings. The top-level `modes` field is a simple list of strings, defining the different modes available to rules. The initial mode is always
the first one in the list. (TODO)
All rules can have a `modes` field that is a list of strings. If no `modes` field is present, the rule will be active in all modes.
## Technical details ## Technical details