Add support for combining 2 axes into one virtual axis. (#11)

Reviewed-on: #11
This commit is contained in:
Anna Rose Wiggins 2025-07-28 17:45:16 +00:00
parent 7b520af24a
commit 3196d4ea22
14 changed files with 321 additions and 61 deletions

View file

@ -66,27 +66,20 @@ rules:
# Vertical thrust is on the VPC "paddles" in the main flight mode # Vertical thrust is on the VPC "paddles" in the main flight mode
- type: axis - type: axis-combined
name: translation up name: translation vertical
modes: modes:
- main - main
input: input_lower:
device: right-stick
axis: ABS_THROTTLE
output:
device: primary
axis: ABS_THROTTLE
- type: axis
name: translation down
modes:
- main
input:
device: left-stick device: left-stick
axis: ABS_THROTTLE axis: Throttle
inverted: true
input_upper:
device: right-stick
axis: Throttle
output: output:
device: primary device: primary
axis: ABS_RUDDER axis: RZ
# By default, the left thumbstick controls tractor beam via mousewheel # By default, the left thumbstick controls tractor beam via mousewheel
- type: axis-to-relaxis - type: axis-to-relaxis
@ -125,30 +118,15 @@ rules:
# In Mining mode, we move vertical thrust to the left thumbstick # In Mining mode, we move vertical thrust to the left thumbstick
# and remap the right paddle to be mining laser power # and remap the right paddle to be mining laser power
- type: axis - type: axis
name: translation up alternate name: translation up/down alternate
modes: modes:
- mining - mining
input: input:
device: left-stick device: left-stick
axis: RY axis: RY
deadzone_start: 29250
deadzone_end: 64000
output: output:
device: primary device: primary
axis: ABS_THROTTLE axis: RZ
- type: axis
name: translation down alternate
modes:
- mining
input:
device: left-stick
axis: RY
deadzone_start: 0
deadzone_end: 30500
output:
device: primary
axis: ABS_RUDDER
- type: axis - type: axis
name: mining laser name: mining laser
@ -156,7 +134,7 @@ rules:
- mining - mining
input: input:
device: right-stick device: right-stick
axis: ABS_THROTTLE axis: Throttle
output: output:
device: primary device: primary
axis: RZ axis: Throttle

View file

@ -6,7 +6,7 @@ devices:
- name: secondary - name: secondary
type: virtual type: virtual
num_buttons: 74 num_buttons: 74
num_axes: 2 num_axes: 3
- name: mouse - name: mouse
type: virtual type: virtual
num_buttons: 0 num_buttons: 0

View file

@ -1,7 +1,10 @@
## multi-file configuration example ## multi-file configuration example
This directory demonstrates how to split your configuration across multiple files. This directory demonstrates how to split your configuration across multiple files.
Note that we re-define the top-level `rules` element in two different files; this is by design. Note that we re-define the top-level `rules` element in two different files; this provides a way to organize
your rules however you like.
It also serves as a real-world example demonstrating many of the available features of the system. It also serves as a real-world example demonstrating many of the available features of the system.
It is copied from the author's actual mappings for Star Citizen. It is copied from the author's actual mappings for Star Citizen, using dual Virpil Constellation Alpha joysticks,
CH Products pedals, and a custom-built button panel. (see https://git.annabunches.net/anna/hardware-projects/src/branch/main/flight-panel-2021-11 for
implementation details of the button panel)

View file

@ -54,6 +54,19 @@ rules:
device: main device: main
axis: ABS_Y axis: ABS_Y
# Create a single merged output axis from 2 input axes
- type: axis-combined
input_lower:
device: flightstick
axis: X
inverted: true # the lower half of the axis will often need to be inverted
input_uppper:
device: flightstick
axis: RX
output:
device: main
axis: RZ
# Straightforward button mapping # Straightforward button mapping
- type: button - type: button
input: input:

View file

@ -32,6 +32,7 @@ All `rules` must have a `type` parameter. Valid values for this parameter are:
* `button-combo` - multiple input buttons mapped to a single output. The output event will trigger when all the input conditions are met. * `button-combo` - multiple input buttons mapped to a single output. The output event will trigger when all the input conditions are met.
* `button-latched` - a single button mapped to a single output, but each time the input is pressed, the output will toggle. * `button-latched` - a single button mapped to a single output, but each time the input is pressed, the output will toggle.
* `axis` - a simple axis mapping * `axis` - a simple axis mapping
* `axis-combined` - a mapping that combines 2 input axes into a single output axis.
* `axis-to-button` - causes an axis input to produce a button output. This can be repeated with variable speed proportional to the axis' input value * `axis-to-button` - causes an axis input to produce a button output. This can be repeated with variable speed proportional to the axis' input value
* `axis-to-relaxis` - like axis-to-button, but produces a "relative axis" output value. This is useful for simulating mouse scrollwheel and movement events. * `axis-to-relaxis` - like axis-to-button, but produces a "relative axis" output value. This is useful for simulating mouse scrollwheel and movement events.

View file

@ -49,6 +49,8 @@ func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice,
newRule, err = makeMappingRuleLatched(ruleConfig, pDevs, vDevs, base) newRule, err = makeMappingRuleLatched(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxis: case RuleTypeAxis:
newRule, err = makeMappingRuleAxis(ruleConfig, pDevs, vDevs, base) newRule, err = makeMappingRuleAxis(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxisCombined:
newRule, err = makeMappingRuleAxisCombined(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxisToButton: case RuleTypeAxisToButton:
newRule, err = makeMappingRuleAxisToButton(ruleConfig, pDevs, vDevs, base) newRule, err = makeMappingRuleAxisToButton(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxisToRelaxis: case RuleTypeAxisToRelaxis:
@ -146,6 +148,29 @@ func makeMappingRuleAxis(ruleConfig RuleConfig,
return mappingrules.NewMappingRuleAxis(base, input, output), nil return mappingrules.NewMappingRuleAxis(base, input, output), nil
} }
func makeMappingRuleAxisCombined(ruleConfig RuleConfig,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisCombined, error) {
inputLower, err := makeRuleTargetAxis(ruleConfig.InputLower, pDevs)
if err != nil {
return nil, err
}
inputUpper, err := makeRuleTargetAxis(ruleConfig.InputUpper, pDevs)
if err != nil {
return nil, err
}
output, err := makeRuleTargetAxis(ruleConfig.Output, vDevs)
if err != nil {
return nil, err
}
return mappingrules.NewMappingRuleAxisCombined(base, inputLower, inputUpper, output), nil
}
func makeMappingRuleAxisToButton(ruleConfig RuleConfig, func makeMappingRuleAxisToButton(ruleConfig RuleConfig,
pDevs map[string]Device, pDevs map[string]Device,
vDevs map[string]Device, vDevs map[string]Device,

View file

@ -32,6 +32,8 @@ type RuleConfig struct {
Name string `yaml:"name,omitempty"` Name string `yaml:"name,omitempty"`
Type string `yaml:"type"` Type string `yaml:"type"`
Input RuleTargetConfig `yaml:"input,omitempty"` Input RuleTargetConfig `yaml:"input,omitempty"`
InputLower RuleTargetConfig `yaml:"input_lower,omitempty"`
InputUpper RuleTargetConfig `yaml:"input_upper,omitempty"`
Inputs []RuleTargetConfig `yaml:"inputs,omitempty"` Inputs []RuleTargetConfig `yaml:"inputs,omitempty"`
Output RuleTargetConfig `yaml:"output"` Output RuleTargetConfig `yaml:"output"`
Modes []string `yaml:"modes,omitempty"` Modes []string `yaml:"modes,omitempty"`

View file

@ -12,6 +12,7 @@ const (
RuleTypeButtonCombo = "button-combo" RuleTypeButtonCombo = "button-combo"
RuleTypeLatched = "button-latched" RuleTypeLatched = "button-latched"
RuleTypeAxis = "axis" RuleTypeAxis = "axis"
RuleTypeAxisCombined = "axis-combined"
RuleTypeModeSelect = "mode-select" RuleTypeModeSelect = "mode-select"
RuleTypeAxisToButton = "axis-to-button" RuleTypeAxisToButton = "axis-to-button"
RuleTypeAxisToRelaxis = "axis-to-relaxis" RuleTypeAxisToRelaxis = "axis-to-relaxis"

View file

@ -0,0 +1,51 @@
package mappingrules
import (
"git.annabunches.net/annabunches/joyful/internal/logger"
"github.com/holoplot/go-evdev"
)
type MappingRuleAxisCombined struct {
MappingRuleBase
InputLower *RuleTargetAxis
InputUpper *RuleTargetAxis
Output *RuleTargetAxis
}
func NewMappingRuleAxisCombined(base MappingRuleBase, inputLower *RuleTargetAxis, inputUpper *RuleTargetAxis, output *RuleTargetAxis) *MappingRuleAxisCombined {
inputLower.OutputMax = 0
inputUpper.OutputMin = 0
return &MappingRuleAxisCombined{
MappingRuleBase: base,
InputLower: inputLower,
InputUpper: inputUpper,
Output: output,
}
}
func (rule *MappingRuleAxisCombined) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) {
if !rule.MappingRuleBase.modeCheck(mode) ||
!(rule.InputLower.MatchEvent(device, event) ||
rule.InputUpper.MatchEvent(device, event)) {
return nil, nil
}
// Since lower and upper are guaranteed to have opposite signs,
// we can just sum them.
var value int32
value += getValueFromAbs(rule.InputLower)
value += getValueFromAbs(rule.InputUpper)
return rule.Output.Device.(*evdev.InputDevice), rule.Output.CreateEvent(value, mode)
}
func getValueFromAbs(ruleTarget *RuleTargetAxis) int32 {
absInfo, err := ruleTarget.Device.AbsInfos()
if err != nil {
logger.LogErrorf(err, "WARNING: Couldn't get axis data for device '%s'", ruleTarget.DeviceName)
return 0
}
return ruleTarget.NormalizeValue(absInfo[ruleTarget.Axis].Value)
}

View file

@ -0,0 +1,142 @@
package mappingrules
import (
"fmt"
"testing"
"github.com/holoplot/go-evdev"
"github.com/stretchr/testify/suite"
)
// TODO: revisit all of this after adding new functionality to
// RuleTargetAxis
type MappingRuleAxisCombinedTests struct {
suite.Suite
inputDevice *InputDeviceMock
outputDevice *evdev.InputDevice
inputTargetLower *RuleTargetAxis
inputTargetUpper *RuleTargetAxis
outputTarget *RuleTargetAxis
base MappingRuleBase
mode *string
}
func TestRunnerMappingRuleAxisCombined(t *testing.T) {
suite.Run(t, new(MappingRuleAxisCombinedTests))
}
func (t *MappingRuleAxisCombinedTests) SetupTest() {
mode := "*"
t.mode = &mode
t.inputDevice = NewInputDeviceMock()
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000},
}, nil)
t.inputTargetLower, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, true, 0, 0)
t.inputTargetUpper, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_Y, false, 0, 0)
t.outputDevice = &evdev.InputDevice{}
t.outputTarget, _ = NewRuleTargetAxis("test-output", t.outputDevice, evdev.ABS_X, false, 0, 0)
t.base = NewMappingRuleBase("", []string{"*"})
// We clear the AbsInfo call here so it can be cleanly set by the (sub-)tests
t.inputDevice.Reset()
}
func (t *MappingRuleAxisCombinedTests) TearDownTest() {
t.inputDevice.Reset()
}
func (t *MappingRuleAxisCombinedTests) TearDownSubTest() {
t.inputDevice.Reset()
}
func (t *MappingRuleAxisCombinedTests) TestNewMappingRuleAxisCombined() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000},
}, nil)
rule := NewMappingRuleAxisCombined(t.base, t.inputTargetLower, t.inputTargetUpper, t.outputTarget)
t.EqualValues(0, rule.InputLower.OutputMax)
t.EqualValues(0, rule.InputUpper.OutputMin)
}
func (t *MappingRuleAxisCombinedTests) TestMatchEvent() {
rule := NewMappingRuleAxisCombined(t.base, t.inputTargetLower, t.inputTargetUpper, t.outputTarget)
t.Run("Lower Input", func() {
testCases := []struct{ in, out int32 }{
{10000, AxisValueMin},
{0, 0},
{5000, AxisValueMin / 2},
}
for _, testCase := range testCases {
t.Run(fmt.Sprintf("%d->%d", testCase.in, testCase.out), func() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000, Value: testCase.in},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000, Value: 0},
}, nil)
device, event := rule.MatchEvent(t.inputDevice, &evdev.InputEvent{Type: evdev.EV_ABS, Code: evdev.ABS_X, Value: testCase.in}, t.mode)
t.Equal(t.outputDevice, device)
t.InDelta(testCase.out, event.Value, 1)
})
}
})
t.Run("Upper Input", func() {
testCases := []struct{ in, out int32 }{
{10000, AxisValueMax},
{0, 0},
{5000, AxisValueMax / 2},
}
for _, testCase := range testCases {
t.Run(fmt.Sprintf("%d->%d", testCase.in, testCase.out), func() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000, Value: 0},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000, Value: testCase.in},
}, nil)
device, event := rule.MatchEvent(t.inputDevice, &evdev.InputEvent{Type: evdev.EV_ABS, Code: evdev.ABS_Y, Value: testCase.in}, t.mode)
t.Equal(t.outputDevice, device)
t.InDelta(testCase.out, event.Value, 1)
})
}
})
t.Run("Combined Inputs", func() {
testCases := []struct{ x, y, out int32 }{
{0, 0, 0},
{5000, 5000, 0},
{10000, 10000, 0},
{5000, 10000, AxisValueMax / 2},
}
for _, testCase := range testCases {
t.Run(fmt.Sprintf("%d,%d->%d", testCase.x, testCase.y, testCase.out), func() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000, Value: testCase.x},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000, Value: testCase.y},
}, nil)
device, event := rule.MatchEvent(t.inputDevice, &evdev.InputEvent{Type: evdev.EV_ABS, Code: evdev.ABS_Y, Value: testCase.y}, t.mode)
t.Equal(t.outputDevice, device)
t.InDelta(testCase.out, event.Value, 1)
})
}
})
// TODO: add tests for exception cases
}

View file

@ -14,6 +14,8 @@ type RuleTargetAxis struct {
Inverted bool Inverted bool
DeadzoneStart int32 DeadzoneStart int32
DeadzoneEnd int32 DeadzoneEnd int32
OutputMin int32
OutputMax int32
axisSize int32 axisSize int32
deadzoneSize int32 deadzoneSize int32
} }
@ -61,6 +63,8 @@ func NewRuleTargetAxis(device_name string,
Device: device, Device: device,
Axis: axis, Axis: axis,
Inverted: inverted, Inverted: inverted,
OutputMin: AxisValueMin,
OutputMax: AxisValueMax,
DeadzoneStart: deadzoneStart, DeadzoneStart: deadzoneStart,
DeadzoneEnd: deadzoneEnd, DeadzoneEnd: deadzoneEnd,
deadzoneSize: deadzoneSize, deadzoneSize: deadzoneSize,
@ -77,7 +81,7 @@ func NewRuleTargetAxis(device_name string,
// in the deadzone, among other things. // in the deadzone, among other things.
func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { func (target *RuleTargetAxis) NormalizeValue(value int32) int32 {
axisStrength := target.GetAxisStrength(value) axisStrength := target.GetAxisStrength(value)
return LerpInt(AxisValueMin, AxisValueMax, axisStrength) return LerpInt(target.OutputMin, target.OutputMax, axisStrength)
} }
func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent {
@ -103,7 +107,7 @@ func (target *RuleTargetAxis) MatchEventDeviceAndCode(device Device, event *evde
// TODO: Add tests // TODO: Add tests
func (target *RuleTargetAxis) InDeadZone(value int32) bool { func (target *RuleTargetAxis) InDeadZone(value int32) bool {
return value >= target.DeadzoneStart && value <= target.DeadzoneEnd return target.deadzoneSize > 0 && value >= target.DeadzoneStart && value <= target.DeadzoneEnd
} }
// GetAxisStrength returns a float between 0.0 and 1.0, representing the proportional // GetAxisStrength returns a float between 0.0 and 1.0, representing the proportional

View file

@ -15,6 +15,10 @@ type RuleTargetAxisTests struct {
call *mock.Call call *mock.Call
} }
func TestRunnerRuleTargetAxisTests(t *testing.T) {
suite.Run(t, new(RuleTargetAxisTests))
}
func (t *RuleTargetAxisTests) SetupTest() { func (t *RuleTargetAxisTests) SetupTest() {
t.mock = new(InputDeviceMock) t.mock = new(InputDeviceMock)
t.call = t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{ t.call = t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
@ -68,26 +72,41 @@ func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() {
func (t *RuleTargetAxisTests) TestNormalizeValue() { func (t *RuleTargetAxisTests) TestNormalizeValue() {
// Basic normalization should work // Basic normalization should work
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) t.Run("Simple normalization", func() {
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(0))) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(5000))) t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(0)))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(5000)))
})
// Normalization with a deadzone should work // Normalization with a deadzone should work
ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000) t.Run("With Deadzone", func() {
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000)
t.True(ruleTarget.NormalizeValue(int32(5001)) < int32(-31000)) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(7500))) t.True(ruleTarget.NormalizeValue(int32(5001)) < int32(-31000))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(7500)))
})
// Normalization on an inverted axis should work t.Run("Inverted", func() {
ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0)
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0))) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0)))
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000))) t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000)))
})
// Normalization past the stated axis bounds should clamp t.Run("Out of bounds", func() { // Normalization past the stated axis bounds should clamp
ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(-30000))) t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(-30000)))
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(30000))) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(30000)))
})
t.Run("With partial output range", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
ruleTarget.OutputMin = 0
ruleTarget.OutputMax = AxisValueMax
t.EqualValues(0, ruleTarget.NormalizeValue(int32(0)))
t.EqualValues(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.InDelta(AxisValueMax/2, ruleTarget.NormalizeValue(int32(5000)), 10.0)
})
} }
func (t *RuleTargetAxisTests) TestMatchEvent() { func (t *RuleTargetAxisTests) TestMatchEvent() {
@ -178,7 +197,3 @@ func (t *RuleTargetAxisTests) TestGetAxisStrength() {
t.Equal(1.0, ruleTarget.GetAxisStrength(0)) t.Equal(1.0, ruleTarget.GetAxisStrength(0))
}) })
} }
func TestRunnerRuleTargetAxisTests(t *testing.T) {
suite.Run(t, new(RuleTargetAxisTests))
}

View file

@ -7,9 +7,32 @@ import (
type InputDeviceMock struct { type InputDeviceMock struct {
mock.Mock mock.Mock
calls []*mock.Call
}
func NewInputDeviceMock() *InputDeviceMock {
m := new(InputDeviceMock)
m.calls = make([]*mock.Call, 0, 10)
return m
} }
func (m *InputDeviceMock) AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) { func (m *InputDeviceMock) AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) {
args := m.Called() args := m.Called()
return args.Get(0).(map[evdev.EvCode]evdev.AbsInfo), args.Error(1) return args.Get(0).(map[evdev.EvCode]evdev.AbsInfo), args.Error(1)
} }
// TODO: this would make a great library class functioning as a slight improvement on testify's
// mocks for instances where we want to redefine behavior more often...
// (alternately, this is possibly an anti-pattern in Go, in which case find a cleaner way to do this and remove)
func (m *InputDeviceMock) Stub(method string) *mock.Call {
call := m.On(method)
m.calls = append(m.calls, call)
return call
}
func (m *InputDeviceMock) Reset() {
for _, call := range m.calls {
call.Unset()
}
m.calls = make([]*mock.Call, 0, 10)
}

View file

@ -41,9 +41,11 @@ Configuration can be fairly complicated and repetitive. If anyone wants to creat
After building (see below) and writing your configuration (see above), just run `joyful`. You can use `joyful --config <directory>` to specify different configuration profiles; just put all the YAML files for a given profile in a unique directory. After building (see below) and writing your configuration (see above), just run `joyful`. You can use `joyful --config <directory>` to specify different configuration profiles; just put all the YAML files for a given profile in a unique directory.
Pressing `<enter>` in the running terminal window will reload the `rules` section of your config files, so you can make changes to your rules without restarting the application. Applying any changes to `devices` or `modes` requires exiting and re-launching the program.
## Technical details ## Technical details
Joyful is written in golang, and uses evdev/uinput to manage devices. Joyful is written in golang, and uses evdev/uinput to manage devices. See `cmd/joyful/main.go` for the program's entry point.
### Build & Install ### Build & Install