From 3196d4ea2267c67f0917fd93b9f54d26505b96b1 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Mon, 28 Jul 2025 17:45:16 +0000 Subject: [PATCH] Add support for combining 2 axes into one virtual axis. (#11) Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/11 --- docs/examples/multiple_files/axes.yml | 48 ++---- docs/examples/multiple_files/devices.yml | 2 +- docs/examples/multiple_files/readme.md | 7 +- docs/examples/ruletypes.yml | 13 ++ docs/readme.md | 1 + internal/config/make_rules.go | 25 +++ internal/config/schema.go | 2 + internal/config/variables.go | 1 + .../mapping_rule_axis_combined.go | 51 +++++++ .../mapping_rule_axis_combined_test.go | 142 ++++++++++++++++++ internal/mappingrules/rule_target_axis.go | 8 +- .../mappingrules/rule_target_axis_test.go | 55 ++++--- internal/mappingrules/test_mocks.go | 23 +++ readme.md | 4 +- 14 files changed, 321 insertions(+), 61 deletions(-) create mode 100644 internal/mappingrules/mapping_rule_axis_combined.go create mode 100644 internal/mappingrules/mapping_rule_axis_combined_test.go diff --git a/docs/examples/multiple_files/axes.yml b/docs/examples/multiple_files/axes.yml index 0794471..3056df3 100644 --- a/docs/examples/multiple_files/axes.yml +++ b/docs/examples/multiple_files/axes.yml @@ -66,27 +66,20 @@ rules: # Vertical thrust is on the VPC "paddles" in the main flight mode - - type: axis - name: translation up + - type: axis-combined + name: translation vertical modes: - main - input: - device: right-stick - axis: ABS_THROTTLE - output: - device: primary - axis: ABS_THROTTLE - - - type: axis - name: translation down - modes: - - main - input: + input_lower: device: left-stick - axis: ABS_THROTTLE + axis: Throttle + inverted: true + input_upper: + device: right-stick + axis: Throttle output: device: primary - axis: ABS_RUDDER + axis: RZ # By default, the left thumbstick controls tractor beam via mousewheel - type: axis-to-relaxis @@ -125,30 +118,15 @@ rules: # In Mining mode, we move vertical thrust to the left thumbstick # and remap the right paddle to be mining laser power - type: axis - name: translation up alternate + name: translation up/down alternate modes: - mining input: device: left-stick axis: RY - deadzone_start: 29250 - deadzone_end: 64000 output: device: primary - axis: ABS_THROTTLE - - - 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 + axis: RZ - type: axis name: mining laser @@ -156,7 +134,7 @@ rules: - mining input: device: right-stick - axis: ABS_THROTTLE + axis: Throttle output: device: primary - axis: RZ + axis: Throttle diff --git a/docs/examples/multiple_files/devices.yml b/docs/examples/multiple_files/devices.yml index f86df9e..156e132 100644 --- a/docs/examples/multiple_files/devices.yml +++ b/docs/examples/multiple_files/devices.yml @@ -6,7 +6,7 @@ devices: - name: secondary type: virtual num_buttons: 74 - num_axes: 2 + num_axes: 3 - name: mouse type: virtual num_buttons: 0 diff --git a/docs/examples/multiple_files/readme.md b/docs/examples/multiple_files/readme.md index 6a9d764..77e9ad3 100644 --- a/docs/examples/multiple_files/readme.md +++ b/docs/examples/multiple_files/readme.md @@ -1,7 +1,10 @@ ## multi-file configuration example 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 is copied from the author's actual mappings for Star Citizen. \ No newline at end of file +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) \ No newline at end of file diff --git a/docs/examples/ruletypes.yml b/docs/examples/ruletypes.yml index 2c976e4..7cb4b3a 100644 --- a/docs/examples/ruletypes.yml +++ b/docs/examples/ruletypes.yml @@ -54,6 +54,19 @@ rules: device: main 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 - type: button input: diff --git a/docs/readme.md b/docs/readme.md index 69c4ae6..58e5453 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -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-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-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-relaxis` - like axis-to-button, but produces a "relative axis" output value. This is useful for simulating mouse scrollwheel and movement events. diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go index 7c1365c..647987c 100644 --- a/internal/config/make_rules.go +++ b/internal/config/make_rules.go @@ -49,6 +49,8 @@ func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice, newRule, err = makeMappingRuleLatched(ruleConfig, pDevs, vDevs, base) case RuleTypeAxis: newRule, err = makeMappingRuleAxis(ruleConfig, pDevs, vDevs, base) + case RuleTypeAxisCombined: + newRule, err = makeMappingRuleAxisCombined(ruleConfig, pDevs, vDevs, base) case RuleTypeAxisToButton: newRule, err = makeMappingRuleAxisToButton(ruleConfig, pDevs, vDevs, base) case RuleTypeAxisToRelaxis: @@ -146,6 +148,29 @@ func makeMappingRuleAxis(ruleConfig RuleConfig, 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, pDevs map[string]Device, vDevs map[string]Device, diff --git a/internal/config/schema.go b/internal/config/schema.go index 5f52756..afb4940 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -32,6 +32,8 @@ type RuleConfig struct { Name string `yaml:"name,omitempty"` Type string `yaml:"type"` Input RuleTargetConfig `yaml:"input,omitempty"` + InputLower RuleTargetConfig `yaml:"input_lower,omitempty"` + InputUpper RuleTargetConfig `yaml:"input_upper,omitempty"` Inputs []RuleTargetConfig `yaml:"inputs,omitempty"` Output RuleTargetConfig `yaml:"output"` Modes []string `yaml:"modes,omitempty"` diff --git a/internal/config/variables.go b/internal/config/variables.go index fa60e6c..9c126bc 100644 --- a/internal/config/variables.go +++ b/internal/config/variables.go @@ -12,6 +12,7 @@ const ( RuleTypeButtonCombo = "button-combo" RuleTypeLatched = "button-latched" RuleTypeAxis = "axis" + RuleTypeAxisCombined = "axis-combined" RuleTypeModeSelect = "mode-select" RuleTypeAxisToButton = "axis-to-button" RuleTypeAxisToRelaxis = "axis-to-relaxis" diff --git a/internal/mappingrules/mapping_rule_axis_combined.go b/internal/mappingrules/mapping_rule_axis_combined.go new file mode 100644 index 0000000..36562b8 --- /dev/null +++ b/internal/mappingrules/mapping_rule_axis_combined.go @@ -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) +} diff --git a/internal/mappingrules/mapping_rule_axis_combined_test.go b/internal/mappingrules/mapping_rule_axis_combined_test.go new file mode 100644 index 0000000..631d7a0 --- /dev/null +++ b/internal/mappingrules/mapping_rule_axis_combined_test.go @@ -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 +} diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 79b4492..fece9b8 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -14,6 +14,8 @@ type RuleTargetAxis struct { Inverted bool DeadzoneStart int32 DeadzoneEnd int32 + OutputMin int32 + OutputMax int32 axisSize int32 deadzoneSize int32 } @@ -61,6 +63,8 @@ func NewRuleTargetAxis(device_name string, Device: device, Axis: axis, Inverted: inverted, + OutputMin: AxisValueMin, + OutputMax: AxisValueMax, DeadzoneStart: deadzoneStart, DeadzoneEnd: deadzoneEnd, deadzoneSize: deadzoneSize, @@ -77,7 +81,7 @@ func NewRuleTargetAxis(device_name string, // in the deadzone, among other things. func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { 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 { @@ -103,7 +107,7 @@ func (target *RuleTargetAxis) MatchEventDeviceAndCode(device Device, event *evde // TODO: Add tests 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 diff --git a/internal/mappingrules/rule_target_axis_test.go b/internal/mappingrules/rule_target_axis_test.go index 28d2fd1..5125b94 100644 --- a/internal/mappingrules/rule_target_axis_test.go +++ b/internal/mappingrules/rule_target_axis_test.go @@ -15,6 +15,10 @@ type RuleTargetAxisTests struct { call *mock.Call } +func TestRunnerRuleTargetAxisTests(t *testing.T) { + suite.Run(t, new(RuleTargetAxisTests)) +} + func (t *RuleTargetAxisTests) SetupTest() { t.mock = new(InputDeviceMock) t.call = t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{ @@ -68,26 +72,41 @@ func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() { func (t *RuleTargetAxisTests) TestNormalizeValue() { // Basic normalization should work - ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) - t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) - t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(0))) - t.EqualValues(0, ruleTarget.NormalizeValue(int32(5000))) + t.Run("Simple normalization", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) + t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) + t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(0))) + t.EqualValues(0, ruleTarget.NormalizeValue(int32(5000))) + }) // Normalization with a deadzone should work - ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000) - t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) - t.True(ruleTarget.NormalizeValue(int32(5001)) < int32(-31000)) - t.EqualValues(0, ruleTarget.NormalizeValue(int32(7500))) + t.Run("With Deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000) + t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) + t.True(ruleTarget.NormalizeValue(int32(5001)) < int32(-31000)) + t.EqualValues(0, ruleTarget.NormalizeValue(int32(7500))) + }) - // Normalization on an inverted axis should work - ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0) - t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0))) - t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000))) + t.Run("Inverted", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0) + t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0))) + t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000))) + }) - // Normalization past the stated axis bounds should clamp - ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) - t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(-30000))) - t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(30000))) + t.Run("Out of bounds", func() { // Normalization past the stated axis bounds should clamp + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) + t.Equal(AxisValueMin, 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() { @@ -178,7 +197,3 @@ func (t *RuleTargetAxisTests) TestGetAxisStrength() { t.Equal(1.0, ruleTarget.GetAxisStrength(0)) }) } - -func TestRunnerRuleTargetAxisTests(t *testing.T) { - suite.Run(t, new(RuleTargetAxisTests)) -} diff --git a/internal/mappingrules/test_mocks.go b/internal/mappingrules/test_mocks.go index 9838731..d8c39c5 100644 --- a/internal/mappingrules/test_mocks.go +++ b/internal/mappingrules/test_mocks.go @@ -7,9 +7,32 @@ import ( type InputDeviceMock struct { 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) { args := m.Called() 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) +} diff --git a/readme.md b/readme.md index d6a9021..633fe32 100644 --- a/readme.md +++ b/readme.md @@ -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 ` to specify different configuration profiles; just put all the YAML files for a given profile in a unique directory. +Pressing `` 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 -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