diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ad0bca5..43ac506 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -28,6 +28,23 @@ } }, "problemMatcher": [] + }, + { + "label": "Test Project", + "args": [ + "test", + "./..." + ], + "group": { + "kind": "test", + "isDefault": true + }, + "options": { + "env": { + "CGO_ENABLED": "0" + } + }, + "problemMatcher": [] } ], } \ No newline at end of file diff --git a/docs/examples/ruletypes.yml b/docs/examples/ruletypes.yml index 74a6b67..2c976e4 100644 --- a/docs/examples/ruletypes.yml +++ b/docs/examples/ruletypes.yml @@ -26,6 +26,34 @@ rules: device: main axis: ABS_X + - type: axis + input: + device: flightstick + # An alternate way to specify deadzones is to define the deadzone's center and then a + # size value. This will create a deadzone that covers a range of deadzone_size, + # centered on the center value. Note that if your deadzone_center is at the lower or upper end + # of the axis, the total size will still be as given; the deadzone will be "shifted" into bounds. + deadzone_center: 29000 + deadzone_size: 2000 + inverted: false + axis: Y # The ABS_ prefix is optional + output: + device: main + axis: ABS_Y + + - type: axis + input: + device: flightstick + # A final way to specify deadzones is to use a size percentage instead of an absolute size. + # This works exactly like deadzone_size, but calculates a percentage of the axis' total range. + deadzone_center: 29000 + deadzone_size_percent: 5 + inverted: false + axis: Y # The ABS_ prefix is optional + output: + device: main + axis: ABS_Y + # Straightforward button mapping - type: button input: diff --git a/docs/readme.md b/docs/readme.md index 340bcd3..f777d04 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -55,6 +55,16 @@ For input, you can figure out what event codes your device is emitting by runnin evtest | grep BTN_ ``` +### Axis Deadzones + +For most axis inputs, you will want to define deadzones. There are three possible approaches: + +* Define `deadzone_start` and `deadzone_end` to explicitly set the deadzone bounds. +* Define `deadzone_center` and `deadzone_size`; this will create a deadzone of the indicated size centered at the given axis position. +* Define `deadzone_center` and `deadzone_size_percent` to use a percentage of the total axis size. + +See for usage examples. + ## Modes Modes are optional, and also have the simplest configuration. To define modes, add this to your configuration: diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go new file mode 100644 index 0000000..0b9fa42 --- /dev/null +++ b/internal/config/interfaces.go @@ -0,0 +1,7 @@ +package config + +import "github.com/holoplot/go-evdev" + +type Device interface { + AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) +} diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go index 5b38347..73f184d 100644 --- a/internal/config/make_rule_targets.go +++ b/internal/config/make_rule_targets.go @@ -4,11 +4,12 @@ import ( "errors" "fmt" + "git.annabunches.net/annabunches/joyful/internal/logger" "git.annabunches.net/annabunches/joyful/internal/mappingrules" "github.com/holoplot/go-evdev" ) -func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetButton, error) { +func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetButton, error) { device, ok := devs[targetConfig.Device] if !ok { return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) @@ -27,7 +28,7 @@ func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]*evdev. ) } -func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetAxis, error) { +func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetAxis, error) { device, ok := devs[targetConfig.Device] if !ok { return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) @@ -42,17 +43,22 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.In return nil, err } + deadzoneStart, deadzoneEnd, err := calculateDeadzones(targetConfig, device, eventCode) + if err != nil { + return nil, err + } + return mappingrules.NewRuleTargetAxis( targetConfig.Device, device, eventCode, targetConfig.Inverted, - targetConfig.DeadzoneStart, - targetConfig.DeadzoneEnd, + deadzoneStart, + deadzoneEnd, ) } -func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetRelaxis, error) { +func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetRelaxis, error) { device, ok := devs[targetConfig.Device] if !ok { return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) @@ -83,3 +89,61 @@ func makeRuleTargetModeSelect(targetConfig RuleTargetConfig, allModes []string) func hasError(_ any, err error) bool { return err != nil } + +// calculateDeadzones produces the deadzone start and end values in absolute terms +// TODO: on the one hand, this logic feels betten encapsulated in mappingrules. On the other hand, +// passing even more parameters to NewRuleTargetAxis feels terrible +func calculateDeadzones(targetConfig RuleTargetConfig, device Device, axis evdev.EvCode) (int32, int32, error) { + + var deadzoneStart, deadzoneEnd int32 + deadzoneStart = 0 + deadzoneEnd = 0 + + if targetConfig.DeadzoneStart != 0 || targetConfig.DeadzoneEnd != 0 { + return targetConfig.DeadzoneStart, targetConfig.DeadzoneEnd, nil + } + + var min, max int32 + absInfoMap, err := device.AbsInfos() + + if err != nil { + min = mappingrules.AxisValueMin + max = mappingrules.AxisValueMax + } else { + absInfo := absInfoMap[axis] + min = absInfo.Minimum + max = absInfo.Maximum + } + + if targetConfig.DeadzoneCenter < min || targetConfig.DeadzoneCenter > max { + return 0, 0, fmt.Errorf("deadzone_center '%d' is out of bounds", targetConfig.DeadzoneCenter) + } + + switch { + case targetConfig.DeadzoneSize != 0: + deadzoneStart = targetConfig.DeadzoneCenter - targetConfig.DeadzoneSize/2 + deadzoneEnd = targetConfig.DeadzoneCenter + targetConfig.DeadzoneSize/2 + case targetConfig.DeadzoneSizePercent != 0: + deadzoneSize := (max - min) / targetConfig.DeadzoneSizePercent + deadzoneStart = targetConfig.DeadzoneCenter - deadzoneSize/2 + deadzoneEnd = targetConfig.DeadzoneCenter + deadzoneSize/2 + } + + deadzoneStart, deadzoneEnd = clampAndShift(deadzoneStart, deadzoneEnd, min, max) + return deadzoneStart, deadzoneEnd, nil +} + +func clampAndShift(start, end, min, max int32) (int32, int32) { + logger.Logf("DEBUG: %d %d %d %d", start, end, min, max) + if start < min { + end += min - start + start = min + logger.Logf("DEBUG: %d %d %d %d", start, end, min, max) + } + if end > max { + start -= end - max + end = max + } + + return start, end +} diff --git a/internal/config/make_rule_targets_test.go b/internal/config/make_rule_targets_test.go index 02de987..6e71fa6 100644 --- a/internal/config/make_rule_targets_test.go +++ b/internal/config/make_rule_targets_test.go @@ -4,12 +4,24 @@ import ( "testing" "github.com/holoplot/go-evdev" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" ) type MakeRuleTargetsTests struct { suite.Suite - devs map[string]*evdev.InputDevice + devs map[string]Device + deviceMock *DeviceMock + config RuleTargetConfig +} + +type DeviceMock struct { + mock.Mock +} + +func (m *DeviceMock) AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) { + args := m.Called() + return args.Get(0).(map[evdev.EvCode]evdev.AbsInfo), args.Error(1) } func TestRunnerMakeRuleTargets(t *testing.T) { @@ -17,131 +29,216 @@ func TestRunnerMakeRuleTargets(t *testing.T) { } func (t *MakeRuleTargetsTests) SetupSuite() { - t.devs = map[string]*evdev.InputDevice{ - "test": {}, + t.deviceMock = new(DeviceMock) + t.deviceMock.On("AbsInfos").Return( + map[evdev.EvCode]evdev.AbsInfo{ + evdev.ABS_X: { + Minimum: 0, + Maximum: 10000, + }, + evdev.ABS_Y: { + Minimum: 0, + Maximum: 10000, + }, + }, nil, + ) + t.devs = map[string]Device{ + "test": t.deviceMock, + } +} + +func (t *MakeRuleTargetsTests) SetupSubTest() { + t.config = RuleTargetConfig{ + Device: "test", } } func (t *MakeRuleTargetsTests) TestMakeRuleTargetButton() { - config := RuleTargetConfig{ - Device: "test", - } t.Run("Standard keycode", func() { - config.Button = "BTN_TRIGGER" - rule, err := makeRuleTargetButton(config, t.devs) + t.config.Button = "BTN_TRIGGER" + rule, err := makeRuleTargetButton(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.BTN_TRIGGER, rule.Button) }) t.Run("Hex code", func() { - config.Button = "0x2fd" - rule, err := makeRuleTargetButton(config, t.devs) + t.config.Button = "0x2fd" + rule, err := makeRuleTargetButton(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.EvCode(0x2fd), rule.Button) }) t.Run("Index", func() { - config.Button = "3" - rule, err := makeRuleTargetButton(config, t.devs) + t.config.Button = "3" + rule, err := makeRuleTargetButton(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.BTN_TOP, rule.Button) }) t.Run("Index too high", func() { - config.Button = "74" - _, err := makeRuleTargetButton(config, t.devs) + t.config.Button = "74" + _, err := makeRuleTargetButton(t.config, t.devs) t.NotNil(err) }) t.Run("Un-prefixed keycode", func() { - config.Button = "pinkie" - rule, err := makeRuleTargetButton(config, t.devs) + t.config.Button = "pinkie" + rule, err := makeRuleTargetButton(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.BTN_PINKIE, rule.Button) }) t.Run("Invalid keycode", func() { - config.Button = "foo" - _, err := makeRuleTargetButton(config, t.devs) + t.config.Button = "foo" + _, err := makeRuleTargetButton(t.config, t.devs) t.NotNil(err) }) } func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { - config := RuleTargetConfig{ - Device: "test", - } - - t.Run("Standard keycode", func() { - config.Axis = "ABS_X" - rule, err := makeRuleTargetAxis(config, t.devs) + t.Run("Standard code", func() { + t.config.Axis = "ABS_X" + rule, err := makeRuleTargetAxis(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.ABS_X, rule.Axis) }) - t.Run("Hex keycode", func() { - config.Axis = "0x01" - rule, err := makeRuleTargetAxis(config, t.devs) + t.Run("Hex code", func() { + t.config.Axis = "0x01" + rule, err := makeRuleTargetAxis(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.ABS_Y, rule.Axis) }) - t.Run("Un-prefixed keycode", func() { - config.Axis = "x" - rule, err := makeRuleTargetAxis(config, t.devs) + t.Run("Un-prefixed code", func() { + t.config.Axis = "x" + rule, err := makeRuleTargetAxis(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.ABS_X, rule.Axis) }) - t.Run("Invalid keycode", func() { - config.Axis = "foo" - _, err := makeRuleTargetAxis(config, t.devs) + t.Run("Invalid code", func() { + t.config.Axis = "foo" + _, err := makeRuleTargetAxis(t.config, t.devs) t.NotNil(err) }) t.Run("Invalid deadzone", func() { - config.DeadzoneEnd = 100 - config.DeadzoneStart = 1000 - _, err := makeRuleTargetAxis(config, t.devs) + t.config.Axis = "x" + t.config.DeadzoneEnd = 100 + t.config.DeadzoneStart = 1000 + _, err := makeRuleTargetAxis(t.config, t.devs) + t.NotNil(err) + }) + + t.Run("Deadzone center/size", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 5000 + t.config.DeadzoneSize = 1000 + rule, err := makeRuleTargetAxis(t.config, t.devs) + t.Nil(err) + t.EqualValues(4500, rule.DeadzoneStart) + t.EqualValues(5500, rule.DeadzoneEnd) + }) + + t.Run("Deadzone center/size lower boundary", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 0 + t.config.DeadzoneSize = 500 + rule, err := makeRuleTargetAxis(t.config, t.devs) + t.Nil(err) + t.EqualValues(0, rule.DeadzoneStart) + t.EqualValues(500, rule.DeadzoneEnd) + }) + + t.Run("Deadzone center/size upper boundary", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 10000 + t.config.DeadzoneSize = 500 + rule, err := makeRuleTargetAxis(t.config, t.devs) + t.Nil(err) + t.EqualValues(9500, rule.DeadzoneStart) + t.EqualValues(10000, rule.DeadzoneEnd) + }) + + t.Run("Deadzone center/size invalid center", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 20000 + t.config.DeadzoneSize = 500 + _, err := makeRuleTargetAxis(t.config, t.devs) + t.NotNil(err) + }) + + t.Run("Deadzone center/percent", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 5000 + t.config.DeadzoneSizePercent = 10 + rule, err := makeRuleTargetAxis(t.config, t.devs) + t.Nil(err) + t.EqualValues(4500, rule.DeadzoneStart) + t.EqualValues(5500, rule.DeadzoneEnd) + }) + + t.Run("Deadzone center/percent lower boundary", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 0 + t.config.DeadzoneSizePercent = 10 + rule, err := makeRuleTargetAxis(t.config, t.devs) + t.Nil(err) + t.EqualValues(0, rule.DeadzoneStart) + t.EqualValues(1000, rule.DeadzoneEnd) + }) + + t.Run("Deadzone center/percent upper boundary", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 10000 + t.config.DeadzoneSizePercent = 10 + rule, err := makeRuleTargetAxis(t.config, t.devs) + t.Nil(err) + t.EqualValues(9000, rule.DeadzoneStart) + t.EqualValues(10000, rule.DeadzoneEnd) + }) + + t.Run("Deadzone center/percent invalid center", func() { + t.config.Axis = "x" + t.config.DeadzoneCenter = 20000 + t.config.DeadzoneSizePercent = 10 + _, err := makeRuleTargetAxis(t.config, t.devs) t.NotNil(err) }) } func (t *MakeRuleTargetsTests) TestMakeRuleTargetRelaxis() { - config := RuleTargetConfig{ - Device: "test", - } - t.Run("Standard keycode", func() { - config.Axis = "REL_WHEEL" - rule, err := makeRuleTargetRelaxis(config, t.devs) + t.config.Axis = "REL_WHEEL" + rule, err := makeRuleTargetRelaxis(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.REL_WHEEL, rule.Axis) }) t.Run("Hex keycode", func() { - config.Axis = "0x00" - rule, err := makeRuleTargetRelaxis(config, t.devs) + t.config.Axis = "0x00" + rule, err := makeRuleTargetRelaxis(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.REL_X, rule.Axis) }) t.Run("Un-prefixed keycode", func() { - config.Axis = "wheel" - rule, err := makeRuleTargetRelaxis(config, t.devs) + t.config.Axis = "wheel" + rule, err := makeRuleTargetRelaxis(t.config, t.devs) t.Nil(err) t.EqualValues(evdev.REL_WHEEL, rule.Axis) }) t.Run("Invalid keycode", func() { - config.Axis = "foo" - _, err := makeRuleTargetRelaxis(config, t.devs) + t.config.Axis = "foo" + _, err := makeRuleTargetRelaxis(t.config, t.devs) t.NotNil(err) }) t.Run("Incorrect axis type", func() { - config.Axis = "ABS_X" - _, err := makeRuleTargetRelaxis(config, t.devs) + t.config.Axis = "ABS_X" + _, err := makeRuleTargetRelaxis(t.config, t.devs) t.NotNil(err) }) } diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go index 6d75d58..7c1365c 100644 --- a/internal/config/make_rules.go +++ b/internal/config/make_rules.go @@ -10,14 +10,25 @@ import ( ) // TODO: At some point it would *very likely* make sense to map each rule to all of the physical devices that can -// trigger it, and return that instead. Something like a map[*evdev.InputDevice][]mappingrule.MappingRule. +// trigger it, and return that instead. Something like a map[Device][]mappingrule.MappingRule. // This would speed up rule matching by only checking relevant rules for a given input event. // We could take this further and make it a map[][]rule // For very large rule-bases this may be helpful for staying performant. -func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule { +func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice, vInputDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule { rules := make([]mappingrules.MappingRule, 0) modes := parser.GetModes() + // Golang can't inspect the concrete map type to determine interface conformance, + // so we handle that here. + pDevs := make(map[string]Device) + for name, dev := range pInputDevs { + pDevs[name] = dev + } + vDevs := make(map[string]Device) + for name, dev := range vInputDevs { + vDevs[name] = dev + } + for _, ruleConfig := range parser.config.Rules { var newRule mappingrules.MappingRule var err error @@ -60,8 +71,8 @@ func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDev } func makeMappingRuleButton(ruleConfig RuleConfig, - pDevs map[string]*evdev.InputDevice, - vDevs map[string]*evdev.InputDevice, + pDevs map[string]Device, + vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButton, error) { input, err := makeRuleTargetButton(ruleConfig.Input, pDevs) @@ -78,8 +89,8 @@ func makeMappingRuleButton(ruleConfig RuleConfig, } func makeMappingRuleCombo(ruleConfig RuleConfig, - pDevs map[string]*evdev.InputDevice, - vDevs map[string]*evdev.InputDevice, + pDevs map[string]Device, + vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonCombo, error) { inputs := make([]*mappingrules.RuleTargetButton, 0) @@ -100,8 +111,8 @@ func makeMappingRuleCombo(ruleConfig RuleConfig, } func makeMappingRuleLatched(ruleConfig RuleConfig, - pDevs map[string]*evdev.InputDevice, - vDevs map[string]*evdev.InputDevice, + pDevs map[string]Device, + vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonLatched, error) { input, err := makeRuleTargetButton(ruleConfig.Input, pDevs) @@ -118,8 +129,8 @@ func makeMappingRuleLatched(ruleConfig RuleConfig, } func makeMappingRuleAxis(ruleConfig RuleConfig, - pDevs map[string]*evdev.InputDevice, - vDevs map[string]*evdev.InputDevice, + pDevs map[string]Device, + vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxis, error) { input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs) @@ -136,8 +147,8 @@ func makeMappingRuleAxis(ruleConfig RuleConfig, } func makeMappingRuleAxisToButton(ruleConfig RuleConfig, - pDevs map[string]*evdev.InputDevice, - vDevs map[string]*evdev.InputDevice, + pDevs map[string]Device, + vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToButton, error) { input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs) @@ -154,8 +165,8 @@ func makeMappingRuleAxisToButton(ruleConfig RuleConfig, } func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig, - pDevs map[string]*evdev.InputDevice, - vDevs map[string]*evdev.InputDevice, + pDevs map[string]Device, + vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToRelaxis, error) { input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs) @@ -176,7 +187,7 @@ func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig, } func makeMappingRuleModeSelect(ruleConfig RuleConfig, - pDevs map[string]*evdev.InputDevice, + pDevs map[string]Device, modes []string, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleModeSelect, error) { diff --git a/internal/config/schema.go b/internal/config/schema.go index c869804..5f52756 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -41,11 +41,14 @@ type RuleConfig struct { } type RuleTargetConfig struct { - Device string `yaml:"device,omitempty"` - Button string `yaml:"button,omitempty"` - Axis string `yaml:"axis,omitempty"` - DeadzoneStart int32 `yaml:"deadzone_start,omitempty"` - DeadzoneEnd int32 `yaml:"deadzone_end,omitempty"` - Inverted bool `yaml:"inverted,omitempty"` - Modes []string `yaml:"modes,omitempty"` + Device string `yaml:"device,omitempty"` + Button string `yaml:"button,omitempty"` + Axis string `yaml:"axis,omitempty"` + DeadzoneCenter int32 `yaml:"deadzone_center,omitempty"` + DeadzoneSize int32 `yaml:"deadzone_size,omitempty"` + DeadzoneSizePercent int32 `yaml:"deadzone_size_percent,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/readme.md b/readme.md index d38fd3f..d6a9021 100644 --- a/readme.md +++ b/readme.md @@ -17,8 +17,8 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe * "Split" axis mapping: map sections of an axis to different outputs using deadzones. * Axis -> button mapping with optional "proportional" repeat speed (i.e. repeat faster as the axis is engaged further) * Axis -> Relative Axis mapping, for converting a joystick axis to mouse movement and scrollwheel events. +* Configure per-rule configurable deadzones for axes, with multiple ways to specify deadzones. * Define multiple modes with per-mode behavior. -* Configure per-rule configurable deadzones for axes. ### Possible Future Features @@ -27,7 +27,6 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe * Output keyboard button presses * Hat support * HIDRAW support for more button options. -* Percentage-based deadzones. * Sensitivity Curves. ## Configuration @@ -40,7 +39,7 @@ Configuration can be fairly complicated and repetitive. If anyone wants to creat ## Usage -After building (see below) and writing your configuration (see above), just run `joyful`. (Feel free to move this somewhere in your path. You can use `--config ` to specify different configuration profiles. +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. ## Technical details