From bada42d68b53285b7d7ffc3df457d8146c20f1ff Mon Sep 17 00:00:00 2001 From: Luwx Date: Sun, 14 Jun 2026 19:25:08 -0300 Subject: [PATCH] initial impl, update models and editing schema --- lib/data/yaml_codec.dart | 3 + lib/data/yaml_io.dart | 3 + lib/domain/edit/schema/edit_schema.dart | 77 +++ lib/domain/edit/schema/edit_schema_extra.dart | 48 ++ lib/l10n/app_en.arb | 35 ++ lib/l10n/app_localizations.dart | 84 +++ lib/l10n/app_localizations_en.dart | 63 ++ lib/model/action.dart | 6 + lib/projections/dirty_providers.dart | 28 +- .../editor/actions/action_list_editor.dart | 2 + .../editor/actions/editors/editor_one.dart | 16 + .../actions/editors/editor_replace_text.dart | 17 +- .../actions/state/action_editor_notifier.dart | 38 +- .../editor/actions/widgets/action_meta.dart | 5 + .../actions/widgets/action_summary.dart | 4 + .../widgets/action_trigger_fields.dart | 19 +- .../actions/widgets/add_action_dialog.dart | 15 + .../actions/widgets/branch_action_card.dart | 556 ++++++++++++++++++ .../editor/state/edit_location_scope.dart | 178 +++++- .../gestures/list/gesture_list_tile.dart | 3 + lib/ui/features/history/history_screen.dart | 3 + test/domain/optics/action_lenses_test.dart | 69 ++- test/yaml_decode_test.dart | 8 +- test/yaml_roundtrip_test.dart | 34 ++ 24 files changed, 1234 insertions(+), 80 deletions(-) create mode 100644 lib/ui/features/gestures/editor/actions/editors/editor_one.dart create mode 100644 lib/ui/features/gestures/editor/actions/widgets/branch_action_card.dart diff --git a/lib/data/yaml_codec.dart b/lib/data/yaml_codec.dart index 7c2176a..7e791f7 100644 --- a/lib/data/yaml_codec.dart +++ b/lib/data/yaml_codec.dart @@ -657,6 +657,9 @@ Action? _parseAction(YamlMap m) { if (m.containsKey('sleep')) { return SleepAction(milliseconds: m['sleep'] as int? ?? 0); } + if (m.containsKey('one')) { + return OneAction(cases: _parseActions(m['one'])); + } if (m.containsKey('function')) { return FunctionAction(expression: m['function'].toString()); } diff --git a/lib/data/yaml_io.dart b/lib/data/yaml_io.dart index f138416..a1a08c7 100644 --- a/lib/data/yaml_io.dart +++ b/lib/data/yaml_io.dart @@ -468,6 +468,9 @@ Map actionToMap(Action action) => switch (action) { }, SleepAction(:final milliseconds) => {'sleep': milliseconds}, FunctionAction(:final expression) => {'function': expression}, + OneAction(:final cases) => { + 'one': cases.map(triggerActionToMap).toList(), + }, RawAction(:final raw) => {'__raw': raw}, }; diff --git a/lib/domain/edit/schema/edit_schema.dart b/lib/domain/edit/schema/edit_schema.dart index 4ccabe8..45a8416 100644 --- a/lib/domain/edit/schema/edit_schema.dart +++ b/lib/domain/edit/schema/edit_schema.dart @@ -57,6 +57,70 @@ final TreeNode swipeModeNode = subtree( ], ); +// Non-recursive TriggerAction subtree used for the cases inside a `one:` +// branch. Mirrors [actionNode] but excludes the OneAction case, which both +// avoids schema recursion and enforces the one-level nesting cap (no branch +// inside a branch). The `branchCase` scope on the list keeps the generated lens +// names distinct from the top-level `action*` family. +final TreeNode branchCaseNode = subtree( + fields: [ + sealed( + TriggerActionMeta.action, + cases: [ + valueCase( + 'command', + fields: [ + prop(CommandActionMeta.command), + prop( + CommandActionMeta.wait, + compare: projected((v) => v?.effectiveWait), + ), + ], + ), + valueCase( + 'plasma', + fields: [ + prop(PlasmaShortcutActionMeta.component), + prop(PlasmaShortcutActionMeta.shortcut), + ], + ), + valueCase( + 'activateWindow', + fields: [prop(ActivateWindowActionMeta.windowId)], + ), + valueCase( + 'replaceText', + fields: [prop(ReplaceTextActionMeta.rules)], + ), + valueCase( + 'sleep', + fields: [prop('duration', property: SleepActionMeta.milliseconds)], + ), + valueCase( + 'function', + fields: [prop(FunctionActionMeta.expression)], + ), + valueCase('raw', fields: [prop(RawActionMeta.raw)]), + valueCase( + 'input', + fields: [prop('inputEntries', property: InputActionMeta.entries)], + ), + ], + ), + prop('triggerOn', property: TriggerActionMeta.on), + prop(TriggerActionMeta.interval, adapter: nullableText()), + prop(TriggerActionMeta.threshold, adapter: nullableText()), + prop(TriggerActionMeta.limit, adapter: nullableInt()), + prop( + TriggerActionMeta.enabled, + compare: projected((v) => v?.effectiveEnabled), + ), + prop(TriggerActionMeta.conflicting), + prop(TriggerActionMeta.conditions), + prop(TriggerActionMeta.id, adapter: nullableText()), + ], +); + final TreeNode actionNode = subtree( fields: [ sealed( @@ -98,6 +162,19 @@ final TreeNode actionNode = subtree( fields: [prop(FunctionActionMeta.expression)], ), valueCase('raw', fields: [prop(RawActionMeta.raw)]), + valueCase( + 'one', + fields: [ + list( + OneActionMeta.cases, + of: branchCaseNode, + scope: 'branchCase', + location: 'BranchCaseLocation', + parentField: 'action', + indexField: 'caseIndex', + ), + ], + ), valueCase( 'input', fields: [prop('inputEntries', property: InputActionMeta.entries)], diff --git a/lib/domain/edit/schema/edit_schema_extra.dart b/lib/domain/edit/schema/edit_schema_extra.dart index 268d43c..26dc834 100644 --- a/lib/domain/edit/schema/edit_schema_extra.dart +++ b/lib/domain/edit/schema/edit_schema_extra.dart @@ -139,6 +139,41 @@ final _gestureActionsPart = LensPart>( Lens> gestureActionsLens(GestureLocation location) => gestureLens(location).then(_gestureActionsPart); +/// Whole-list lens for the cases of a `one:` ([OneAction]) branch at +/// [location]. Reads `[]` when the addressed action isn't a [OneAction] or the +/// index is out of range; writes by replacing that action with a new +/// [OneAction] carrying the given cases (a no-op when the target isn't a +/// branch). Mirrors [gestureActionsLens] for the nested case list — the +/// per-case field lenses (`branchCase*Lens`) are generated. +Lens> branchCasesLens( + ActionLocation location, +) => Lens>( + get: (config) { + final actions = gestureActionsLens(location.gesture).get(config); + if (location.actionIndex < 0 || location.actionIndex >= actions.length) { + return const []; + } + final action = actions[location.actionIndex].action; + return action is OneAction ? action.cases : const []; + }, + set: (config, cases) { + final actions = gestureActionsLens(location.gesture).get(config); + if (location.actionIndex < 0 || location.actionIndex >= actions.length) { + return config; + } + final current = actions[location.actionIndex]; + if (current.action is! OneAction) return config; + final next = List.of(actions); + next[location.actionIndex] = current.copyWith( + action: OneAction(cases: cases), + ); + return gestureActionsLens(location.gesture).set(config, next); + }, + name: + 'gesture[${location.gesture}].action' + '[${location.actionIndex}].cases', +); + Lens defaultDevicePropertiesLens(DeviceType device) => Lens( get: (config) => @@ -343,3 +378,16 @@ TriggerAction? actionAt(Config? config, ActionLocation location) { } return common.actions[location.actionIndex]; } + +/// The [TriggerAction] for a single case of a `one:` branch, or null when the +/// addressed action isn't a [OneAction] or any index is out of range. +TriggerAction? branchCaseAt(Config? config, BranchCaseLocation location) { + if (config == null) return null; + final parent = ActionLocation( + gesture: location.action, + actionIndex: location.actionIndex, + ); + final cases = branchCasesLens(parent).get(config); + if (location.caseIndex < 0 || location.caseIndex >= cases.length) return null; + return cases[location.caseIndex]; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c80c594..4f4f36a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -295,6 +295,16 @@ "actionMetaFunctionSubtitle": "Run a JavaScript function", "actionMetaRawLabel": "Raw YAML", "actionMetaRawSubtitle": "Hand-authored unsupported action config", + "actionMetaOneLabel": "First match", + "actionMetaOneSubtitle": "Run one action depending on conditions", + "actionMetaOneSummary": "{count, plural, =0{no cases} =1{1 case} other{{count} cases}}", + "@actionMetaOneSummary": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "actionSummaryNoCommand": "No command", "actionSummaryNotConfigured": "Not configured", "actionSummaryEmpty": "Empty", @@ -657,6 +667,31 @@ "dialogUnsavedChangesBody": "You have unsaved changes. Apply them or discard before leaving.", "actionApply": "Apply", "addAction": "Add Action", + "branchHeader_firstMatch": "First match", + "branchHeader_switchOn": "Switch on", + "branchHeader_caseCount": "{count, plural, =0{No cases yet} =1{1 case} other{{count} cases}}", + "@branchHeader_caseCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "branchAddCase": "Add case", + "branchAddDefault": "Add default", + "branchCase_anyValue": "any value", + "branchCase_deadWarning": "Never runs: a default above always matches", + "branchCase_conditionTitle": "Runs when", + "branchAddCaseFor": "Add {discriminator}", + "@branchAddCaseFor": { + "placeholders": { + "discriminator": { + "type": "String" + } + } + }, + "branchCase_when": "when", + "branchCase_otherwise": "otherwise", "dialogAddActionTitle": "Add action", "actionIntervalHint": "+, -, or number", "actionLimitHint": "0 = unlimited", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c74032b..599dc3e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1480,6 +1480,24 @@ abstract class AppLocalizations { /// **'Hand-authored unsupported action config'** String get actionMetaRawSubtitle; + /// No description provided for @actionMetaOneLabel. + /// + /// In en, this message translates to: + /// **'First match'** + String get actionMetaOneLabel; + + /// No description provided for @actionMetaOneSubtitle. + /// + /// In en, this message translates to: + /// **'Run one action depending on conditions'** + String get actionMetaOneSubtitle; + + /// No description provided for @actionMetaOneSummary. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{no cases} =1{1 case} other{{count} cases}}'** + String actionMetaOneSummary(int count); + /// No description provided for @actionSummaryNoCommand. /// /// In en, this message translates to: @@ -3382,6 +3400,72 @@ abstract class AppLocalizations { /// **'Add Action'** String get addAction; + /// No description provided for @branchHeader_firstMatch. + /// + /// In en, this message translates to: + /// **'First match'** + String get branchHeader_firstMatch; + + /// No description provided for @branchHeader_switchOn. + /// + /// In en, this message translates to: + /// **'Switch on'** + String get branchHeader_switchOn; + + /// No description provided for @branchHeader_caseCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No cases yet} =1{1 case} other{{count} cases}}'** + String branchHeader_caseCount(int count); + + /// No description provided for @branchAddCase. + /// + /// In en, this message translates to: + /// **'Add case'** + String get branchAddCase; + + /// No description provided for @branchAddDefault. + /// + /// In en, this message translates to: + /// **'Add default'** + String get branchAddDefault; + + /// No description provided for @branchCase_anyValue. + /// + /// In en, this message translates to: + /// **'any value'** + String get branchCase_anyValue; + + /// No description provided for @branchCase_deadWarning. + /// + /// In en, this message translates to: + /// **'Never runs: a default above always matches'** + String get branchCase_deadWarning; + + /// No description provided for @branchCase_conditionTitle. + /// + /// In en, this message translates to: + /// **'Runs when'** + String get branchCase_conditionTitle; + + /// No description provided for @branchAddCaseFor. + /// + /// In en, this message translates to: + /// **'Add {discriminator}'** + String branchAddCaseFor(String discriminator); + + /// No description provided for @branchCase_when. + /// + /// In en, this message translates to: + /// **'when'** + String get branchCase_when; + + /// No description provided for @branchCase_otherwise. + /// + /// In en, this message translates to: + /// **'otherwise'** + String get branchCase_otherwise; + /// No description provided for @dialogAddActionTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 088cc7e..975de15 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -744,6 +744,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionMetaRawSubtitle => 'Hand-authored unsupported action config'; + @override + String get actionMetaOneLabel => 'First match'; + + @override + String get actionMetaOneSubtitle => 'Run one action depending on conditions'; + + @override + String actionMetaOneSummary(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count cases', + one: '1 case', + zero: 'no cases', + ); + return '$_temp0'; + } + @override String get actionSummaryNoCommand => 'No command'; @@ -1881,6 +1899,51 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addAction => 'Add Action'; + @override + String get branchHeader_firstMatch => 'First match'; + + @override + String get branchHeader_switchOn => 'Switch on'; + + @override + String branchHeader_caseCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count cases', + one: '1 case', + zero: 'No cases yet', + ); + return '$_temp0'; + } + + @override + String get branchAddCase => 'Add case'; + + @override + String get branchAddDefault => 'Add default'; + + @override + String get branchCase_anyValue => 'any value'; + + @override + String get branchCase_deadWarning => + 'Never runs: a default above always matches'; + + @override + String get branchCase_conditionTitle => 'Runs when'; + + @override + String branchAddCaseFor(String discriminator) { + return 'Add $discriminator'; + } + + @override + String get branchCase_when => 'when'; + + @override + String get branchCase_otherwise => 'otherwise'; + @override String get dialogAddActionTitle => 'Add action'; diff --git a/lib/model/action.dart b/lib/model/action.dart index 5b057da..7042e72 100644 --- a/lib/model/action.dart +++ b/lib/model/action.dart @@ -48,6 +48,12 @@ sealed class Action with _$Action { const factory Action.sleep({required int milliseconds}) = SleepAction; + /// First-match dispatcher — the daemon's `one:`. Exactly one of [cases] runs: + /// the first whose conditions match. A case with null conditions is the + /// unconditional default ("otherwise"), conventionally placed last. + const factory Action.one({@Default([]) List cases}) = + OneAction; + /// A JavaScript function executed by the daemon for its side effects (the /// return value is ignored). [expression] is the raw `() => ...` source. const factory Action.function({required String expression}) = FunctionAction; diff --git a/lib/projections/dirty_providers.dart b/lib/projections/dirty_providers.dart index 630c732..8c589e9 100644 --- a/lib/projections/dirty_providers.dart +++ b/lib/projections/dirty_providers.dart @@ -3,7 +3,11 @@ import 'package:flutter_riverpod/misc.dart'; import 'package:input_actions_editor/domain/diff/dirty_locations.dart'; import 'package:input_actions_editor/domain/diff/dirty_semantics.dart'; import 'package:input_actions_editor/domain/edit/schema/edit_schema.dart' - show ActionLocation, GestureLocation, comparableTriggerActionValue; + show + ActionLocation, + BranchCaseLocation, + GestureLocation, + comparableTriggerActionValue; import 'package:input_actions_editor/domain/edit/schema/edit_schema_extra.dart'; import 'package:input_actions_editor/domain/edit/schema/lens.dart'; import 'package:input_actions_editor/model/config.dart'; @@ -95,6 +99,28 @@ final ProviderFamily actionDirtyProvider = selectSession(ref, (s) => _actionDirtyState(s, location).isDirty), ); +final ProviderFamily branchCaseDirtyProvider = + Provider.family( + (ref, location) => + selectSession(ref, (s) => _branchCaseDirtyState(s, location).isDirty), + ); + +DirtyMarkState _branchCaseDirtyState( + EditSession session, + BranchCaseLocation location, +) { + final saved = comparableTriggerActionValue( + branchCaseAt(session.saved, location), + ); + return dirtyMarkState( + current: comparableTriggerActionValue( + branchCaseAt(session.draft, location), + ), + saved: saved, + hasSavedBacking: saved != null, + ); +} + DirtyMarkState _lensDirtyState(EditSession session, Lens lens) { final currentRead = _readLens(session.draft, lens); final savedRead = _readLens(session.saved, lens); diff --git a/lib/ui/features/gestures/editor/actions/action_list_editor.dart b/lib/ui/features/gestures/editor/actions/action_list_editor.dart index 48cea87..dd2541d 100644 --- a/lib/ui/features/gestures/editor/actions/action_list_editor.dart +++ b/lib/ui/features/gestures/editor/actions/action_list_editor.dart @@ -14,6 +14,7 @@ import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_command.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_function.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_input_action.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_one.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_plasma_shortcut.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_raw.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_replace_text.dart'; @@ -468,6 +469,7 @@ class _ExpandedEditor extends HookConsumerWidget { ActionKind.replaceText => const EditorReplaceText(), ActionKind.sleep => const EditorSleep(), ActionKind.function => const EditorFunction(), + ActionKind.one => const EditorOne(), ActionKind.raw => const EditorRaw(), ActionKind.missing => const SizedBox.shrink(), }, diff --git a/lib/ui/features/gestures/editor/actions/editors/editor_one.dart b/lib/ui/features/gestures/editor/actions/editors/editor_one.dart new file mode 100644 index 0000000..8400b42 --- /dev/null +++ b/lib/ui/features/gestures/editor/actions/editors/editor_one.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart' hide Action; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/widgets/branch_action_card.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/state/edit_location_scope.dart'; + +/// Editor for [ActionKind.one]: a first-match branch. Renders the +/// [BranchActionCard] for the [OneAction] addressed by this action location; +/// each case is edited through its own [BranchCaseLocation]. +class EditorOne extends ConsumerWidget { + const EditorOne({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BranchActionCard(parent: context.actionLocation); + } +} diff --git a/lib/ui/features/gestures/editor/actions/editors/editor_replace_text.dart b/lib/ui/features/gestures/editor/actions/editors/editor_replace_text.dart index 8b3c02b..c38b947 100644 --- a/lib/ui/features/gestures/editor/actions/editors/editor_replace_text.dart +++ b/lib/ui/features/gestures/editor/actions/editors/editor_replace_text.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart' hide Action; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; +import 'package:input_actions_editor/domain/edit/schema/edit_schema.dart'; import 'package:input_actions_editor/model/action.dart'; import 'package:input_actions_editor/ui/common/label_with_tooltip.dart'; -import 'package:input_actions_editor/ui/features/gestures/editor/actions/state/action_editor_notifier.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/state/edit_location_scope.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/tooltips/tooltip_widgets.dart'; import 'package:input_actions_editor/ui/l10n/context_ext.dart'; @@ -14,16 +14,15 @@ class EditorReplaceText extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; - final location = context.actionLocation; - final vm = ref.watch(actionEditorProvider(location)); - final action = vm.action?.action; - final rules = action is ReplaceTextAction - ? action.rules - : const []; - final notifier = ref.read(actionEditorProvider(location).notifier); + final rulesField = ref.actionField( + context, + actionRulesLens, + fallbackValue: () => const [], + ); + final rules = rulesField.value; void replaceRules(List next) { - notifier.replaceTextRules(next); + rulesField.onChanged(next); } void addRule() { diff --git a/lib/ui/features/gestures/editor/actions/state/action_editor_notifier.dart b/lib/ui/features/gestures/editor/actions/state/action_editor_notifier.dart index 89b241d..3ce7d71 100644 --- a/lib/ui/features/gestures/editor/actions/state/action_editor_notifier.dart +++ b/lib/ui/features/gestures/editor/actions/state/action_editor_notifier.dart @@ -76,6 +76,7 @@ enum ActionKind { replaceText, sleep, function, + one, raw, missing, } @@ -172,42 +173,6 @@ class ActionEditorNotifier extends Notifier { hasNonDefaultTriggerOptions: actionHasNonDefaultTriggerOptions(action), ); } - - void replaceInputEntries(List entries) { - ref - .read(configControllerProvider.notifier) - .add( - SetLens>( - actionInputEntriesLens(location), - entries, - label: 'Edit input entries', - ), - scope: location.gesture, - ); - } - - void replaceTextRules(List rules) { - final config = ref.read(draftConfigProvider); - final actions = gestureActionsLens(location.gesture).get(config); - if (location.actionIndex < 0 || location.actionIndex >= actions.length) { - return; - } - final current = actions[location.actionIndex]; - ref - .read(configControllerProvider.notifier) - .add( - SetLens>( - gestureActionsLens(location.gesture), - [ - ...actions.take(location.actionIndex), - current.copyWith(action: ReplaceTextAction(rules: rules)), - ...actions.skip(location.actionIndex + 1), - ], - label: 'Edit replace text rules', - ), - scope: location.gesture, - ); - } } ActionKind _kindOf(Action? action) => switch (action) { @@ -218,6 +183,7 @@ ActionKind _kindOf(Action? action) => switch (action) { ReplaceTextAction() => ActionKind.replaceText, SleepAction() => ActionKind.sleep, FunctionAction() => ActionKind.function, + OneAction() => ActionKind.one, RawAction() => ActionKind.raw, null => ActionKind.missing, }; diff --git a/lib/ui/features/gestures/editor/actions/widgets/action_meta.dart b/lib/ui/features/gestures/editor/actions/widgets/action_meta.dart index 2756023..7f2d204 100644 --- a/lib/ui/features/gestures/editor/actions/widgets/action_meta.dart +++ b/lib/ui/features/gestures/editor/actions/widgets/action_meta.dart @@ -53,6 +53,11 @@ ActionMetaInfo actionMeta(Action action, AppLocalizations l10n) => subtitle: l10n.actionMetaFunctionSubtitle, icon: FLucideIcons.braces, ), + OneAction() => ActionMetaInfo( + label: l10n.actionMetaOneLabel, + subtitle: l10n.actionMetaOneSubtitle, + icon: FLucideIcons.gitFork, + ), RawAction() => ActionMetaInfo( label: l10n.actionMetaRawLabel, subtitle: l10n.actionMetaRawSubtitle, diff --git a/lib/ui/features/gestures/editor/actions/widgets/action_summary.dart b/lib/ui/features/gestures/editor/actions/widgets/action_summary.dart index 6ac1c98..db88895 100644 --- a/lib/ui/features/gestures/editor/actions/widgets/action_summary.dart +++ b/lib/ui/features/gestures/editor/actions/widgets/action_summary.dart @@ -33,6 +33,9 @@ String actionValueSummary( expression.trim().isEmpty ? l10n.actionSummaryNotConfigured : expression.trim().split('\n').first, + OneAction(:final cases) => l10n.actionMetaOneSummary( + cases.where((c) => c.conditions != null).length, + ), RawAction(:final raw) => raw.trim().isEmpty ? l10n.actionSummaryEmpty : raw.trim().split('\n').first, }; @@ -51,6 +54,7 @@ String actionRowTitle(Action action, AppLocalizations l10n) => switch (action) { ReplaceTextAction() => l10n.actionMetaReplaceTextLabel, SleepAction() => l10n.actionMetaSleepLabel, FunctionAction() => l10n.actionMetaFunctionLabel, + OneAction() => l10n.actionMetaOneLabel, RawAction() => l10n.actionMetaRawLabel, }; diff --git a/lib/ui/features/gestures/editor/actions/widgets/action_trigger_fields.dart b/lib/ui/features/gestures/editor/actions/widgets/action_trigger_fields.dart index 0c0b140..eb3cd95 100644 --- a/lib/ui/features/gestures/editor/actions/widgets/action_trigger_fields.dart +++ b/lib/ui/features/gestures/editor/actions/widgets/action_trigger_fields.dart @@ -5,9 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:input_actions_editor/domain/edit/schema/edit_schema.dart'; import 'package:input_actions_editor/model/action.dart'; import 'package:input_actions_editor/model/enums.dart'; +import 'package:input_actions_editor/store/config_controller.dart'; import 'package:input_actions_editor/ui/common/label_with_tooltip.dart'; import 'package:input_actions_editor/ui/common/unsaved_marker.dart'; -import 'package:input_actions_editor/ui/features/gestures/editor/actions/state/action_editor_notifier.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/conditions/condition_editor.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/state/edit_location_scope.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/tooltips/tooltip_widgets.dart'; @@ -47,15 +47,14 @@ class ActionTriggerFields extends HookConsumerWidget { final visibleFields = fields.toSet(); if (visibleFields.isEmpty) return const SizedBox.shrink(); - final actionLocation = context.actionLocation; - final (:showInterval, :showThreshold) = ref.watch( - actionEditorProvider(actionLocation).select( - (vm) => ( - showInterval: vm.showInterval, - showThreshold: vm.showThreshold, - ), - ), + final address = context.actionAddress; + // `on` drives which timing fields are relevant; read it through the + // address so this works for both top-level actions and branch cases. + final on = ref.watch( + draftConfigProvider.select((config) => address.read(config)?.on), ); + final showInterval = on == TriggerOn.update || on == TriggerOn.tick; + final showThreshold = on != null && on != TriggerOn.begin; final triggerOnField = ref.actionField( context, actionTriggerOnLens, @@ -203,7 +202,7 @@ class ActionTriggerFields extends HookConsumerWidget { const SizedBox(height: 16), ConditionEditor.generic( title: context.l10n.actionConditionsTitle, - heroTag: actionLocation, + heroTag: address.heroTag, dirtyState: conditionsField.dirty, onRevert: conditionsField.onRevert, titleTooltipContent: const ActionConditionsTooltip(), diff --git a/lib/ui/features/gestures/editor/actions/widgets/add_action_dialog.dart b/lib/ui/features/gestures/editor/actions/widgets/add_action_dialog.dart index ab3a4ca..31ec673 100644 --- a/lib/ui/features/gestures/editor/actions/widgets/add_action_dialog.dart +++ b/lib/ui/features/gestures/editor/actions/widgets/add_action_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' hide Action; import 'package:forui/forui.dart'; import 'package:input_actions_editor/model/action.dart'; +import 'package:input_actions_editor/model/condition.dart'; import 'package:input_actions_editor/ui/common/app_dialog.dart'; import 'package:input_actions_editor/ui/features/gestures/editor/actions/widgets/action_meta.dart'; import 'package:input_actions_editor/ui/l10n/context_ext.dart'; @@ -51,6 +52,7 @@ enum _ActionKind { replaceText, sleep, function, + one, raw; Action buildDefault() => switch (this) { @@ -73,6 +75,19 @@ enum _ActionKind { ), _ActionKind.sleep => const SleepAction(milliseconds: 500), _ActionKind.function => const FunctionAction(expression: '() => '), + _ActionKind.one => const OneAction( + cases: [ + TriggerAction( + action: CommandAction(command: ''), + conditions: VariableCondition( + variable: 'window_class', + operator: '==', + value: '', + ), + ), + TriggerAction(action: CommandAction(command: '')), + ], + ), _ActionKind.raw => const RawAction(raw: ''), }; } diff --git a/lib/ui/features/gestures/editor/actions/widgets/branch_action_card.dart b/lib/ui/features/gestures/editor/actions/widgets/branch_action_card.dart new file mode 100644 index 0000000..76222dc --- /dev/null +++ b/lib/ui/features/gestures/editor/actions/widgets/branch_action_card.dart @@ -0,0 +1,556 @@ +import 'package:flutter/material.dart' hide Action; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:forui/forui.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:input_actions_editor/domain/edit/config_edit.dart'; +import 'package:input_actions_editor/domain/edit/schema/edit_schema.dart'; +import 'package:input_actions_editor/domain/edit/schema/edit_schema_extra.dart' + show branchCaseAt, branchCasesLens; +import 'package:input_actions_editor/model/action.dart'; +import 'package:input_actions_editor/model/condition.dart'; +import 'package:input_actions_editor/projections/dirty_providers.dart'; +import 'package:input_actions_editor/store/config_controller.dart'; +import 'package:input_actions_editor/ui/common/unsaved_marker.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_activate_window.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_command.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_function.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_input_action.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_plasma_shortcut.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_raw.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_replace_text.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/editors/editor_sleep.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/widgets/action_meta.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/widgets/action_summary.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/actions/widgets/action_trigger_fields.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/conditions/catalog/variable_catalog.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/conditions/condition_editor.dart'; +import 'package:input_actions_editor/ui/features/gestures/editor/state/edit_location_scope.dart'; +import 'package:input_actions_editor/ui/l10n/context_ext.dart'; + +// --------------------------------------------------------------------------- +// First-match ("one:") branch editor. +// +// The daemon (ActionGroup, First mode) runs exactly one case: the first whose +// conditions are satisfied, then stops. So order is significant and an +// unconditional case ("Otherwise") runs at its position — anything after it is +// unreachable. The UI therefore renders cases in stored order (no reordering of +// the default), shows each case's condition as a caption above its real, +// editable action row, and warns about unreachable cases. +// --------------------------------------------------------------------------- + +/// Short "when X" descriptor for a case condition, e.g. `konsole` for +/// `$window_class == konsole`. Returns null for the unconditional default. +String? branchCaseDescriptor(Condition? c) => switch (c) { + null => null, + VariableCondition(:final variable, :final operator, :final value) => + operator == '==' + ? value + : '${findVariable(variable)?.pickerName ?? variable} $operator $value', + ConditionGroup(:final mode, :final children) + when mode == ConditionGroupMode.any && + children.every((e) => e is VariableCondition) => + children.map((e) => (e as VariableCondition).value).join(', '), + ConditionGroup(:final children) => '${children.length} conditions', + FunctionCondition() => 'ƒ custom', + RawCondition(:final raw) => raw.split('\n').first, +}; + +/// If every case discriminates on the same variable, its catalog entry (so the +/// header can name it, e.g. "Active window - app class"); else null. +VariableInfo? branchDiscriminator(Iterable cases) { + String? shared; + for (final c in cases) { + final cond = c.conditions; + if (cond == null) continue; + final variable = switch (cond) { + VariableCondition(:final variable) => variable, + ConditionGroup(:final children) + when children.isNotEmpty && + children.every((e) => e is VariableCondition) => + (children.first as VariableCondition).variable, + _ => null, + }; + if (variable == null) return null; + if (shared != null && shared != variable) return null; + shared = variable; + } + return shared == null ? null : findVariable(shared); +} + +/// Index of the first unconditional case, or null. Cases after it never run. +int? _firstUnconditionalIndex(List cases) { + for (var i = 0; i < cases.length; i++) { + if (cases[i].conditions == null) return i; + } + return null; +} + +// --- Order-preserving case mutations. ------------------------------------- + +List reorderBranchCase( + List cases, + int oldIndex, + int newIndex, +) { + final next = List.of(cases); + next.insert(newIndex, next.removeAt(oldIndex)); + return next; +} + +List removeBranchCase(List cases, int index) => + List.of(cases)..removeAt(index); + +/// Inserts a new conditional case before the first unconditional default (so it +/// is reachable), discriminating on the branch's shared variable. +List addBranchCase(List cases) { + final variable = branchDiscriminator(cases)?.name ?? 'window_class'; + final newCase = TriggerAction( + action: const CommandAction(command: ''), + conditions: VariableCondition( + variable: variable, + operator: '==', + value: '', + ), + ); + final at = _firstUnconditionalIndex(cases) ?? cases.length; + return List.of(cases)..insert(at, newCase); +} + +/// Appends an unconditional default ("Otherwise") if the branch has none. +List addBranchDefault(List cases) { + if (_firstUnconditionalIndex(cases) != null) return cases; + return [ + ...cases, + const TriggerAction(action: CommandAction(command: '')), + ]; +} + +/// The editable branch body. [parent] addresses the [OneAction]; each case is a +/// [BranchCaseLocation] under it. +class BranchActionCard extends HookConsumerWidget { + const BranchActionCard({required this.parent, super.key}); + + final ActionLocation parent; + + void _writeCases(WidgetRef ref, List cases) { + ref + .read(configControllerProvider.notifier) + .add( + SetLens>( + branchCasesLens(parent), + cases, + label: 'Edit branch cases', + ), + scope: parent.gesture, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final colors = context.theme.colors; + final typography = context.theme.typography; + final cases = ref.watch( + draftConfigProvider.select((c) => branchCasesLens(parent).get(c)), + ); + final expanded = useState>({}); + final firstDefault = _firstUnconditionalIndex(cases); + final hasDefault = firstDefault != null; + + BranchCaseLocation caseLoc(int i) => BranchCaseLocation( + action: parent.gesture, + actionIndex: parent.actionIndex, + caseIndex: i, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8, left: 2), + child: Text( + l10n.branchHeader_caseCount( + cases.where((c) => c.conditions != null).length, + ), + style: typography.xs.copyWith(color: colors.mutedForeground), + ), + ), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: cases.length, + onReorderItem: (oldIndex, newIndex) { + expanded.value = {}; + _writeCases(ref, reorderBranchCase(cases, oldIndex, newIndex)); + }, + proxyDecorator: (child, index, animation) => + Material(color: Colors.transparent, child: child), + itemBuilder: (context, index) { + // A case is unreachable when an unconditional default precedes it. + final isDead = hasDefault && index > firstDefault; + return _BranchCaseRow( + key: ValueKey('branch-case-$index'), + index: index, + location: caseLoc(index), + isDead: isDead, + expanded: expanded.value.contains(index), + onToggle: () { + final set = Set.of(expanded.value); + set.contains(index) ? set.remove(index) : set.add(index); + expanded.value = set; + }, + onDelete: () { + expanded.value = {}; + _writeCases(ref, removeBranchCase(cases, index)); + }, + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + FButton( + variant: .ghost, + onPress: () => _writeCases(ref, addBranchCase(cases)), + prefix: const Icon(FLucideIcons.plus, size: 14), + child: Text(l10n.branchAddCase), + ), + const SizedBox(width: 8), + if (!hasDefault) + FButton( + variant: .ghost, + onPress: () => _writeCases(ref, addBranchDefault(cases)), + prefix: const Icon(FLucideIcons.cornerDownRight, size: 14), + child: Text(l10n.branchAddDefault), + ), + ], + ), + ], + ); + } +} + +class _BranchCaseRow extends HookConsumerWidget { + const _BranchCaseRow({ + required this.index, + required this.location, + required this.isDead, + required this.expanded, + required this.onToggle, + required this.onDelete, + super.key, + }); + + final int index; + final BranchCaseLocation location; + final bool isDead; + final bool expanded; + final VoidCallback onToggle; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.theme.colors; + final action = ref.watch( + draftConfigProvider.select((c) => branchCaseAt(c, location)), + ); + final isDirty = ref.watch(branchCaseDirtyProvider(location)); + if (action == null) return const SizedBox.shrink(); + + final descriptor = branchCaseDescriptor(action.conditions); + final isDefault = action.conditions == null; + + return EditLocationScope( + branchCase: location, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CaptionRow( + descriptor: descriptor, + isDefault: isDefault, + isDead: isDead, + ), + const SizedBox(height: 4), + Opacity( + opacity: isDead ? 0.55 : 1, + child: Container( + decoration: BoxDecoration( + color: expanded + ? colors.foreground.withValues(alpha: 0.03) + : Colors.transparent, + border: Border.all(color: colors.border), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + _Header( + index: index, + action: action.action, + isDirty: isDirty, + expanded: expanded, + onToggle: onToggle, + onDelete: onDelete, + ), + AnimatedSize( + duration: Durations.medium1, + curve: Easing.standard, + alignment: Alignment.topCenter, + child: expanded + ? Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: _CaseExpanded(action: action.action), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Full-width condition caption shown above a case's row. +class _CaptionRow extends StatelessWidget { + const _CaptionRow({ + required this.descriptor, + required this.isDefault, + required this.isDead, + }); + + final String? descriptor; + final bool isDefault; + final bool isDead; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = context.theme.colors; + final typography = context.theme.typography; + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + children: [ + if (isDefault) + Text( + l10n.branchCase_otherwise, + style: typography.sm.copyWith( + fontWeight: FontWeight.w600, + color: colors.mutedForeground, + ), + ) + else ...[ + Text( + l10n.branchCase_when, + style: typography.sm.copyWith(color: colors.mutedForeground), + ), + const SizedBox(width: 6), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + (descriptor == null || descriptor!.isEmpty) + ? l10n.branchCase_anyValue + : descriptor!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: typography.xs.copyWith( + fontWeight: FontWeight.w700, + color: colors.primary, + ), + ), + ), + ), + ], + if (isDead) ...[ + const SizedBox(width: 10), + Icon( + FLucideIcons.triangleAlert, + size: 13, + color: colors.error, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + l10n.branchCase_deadWarning, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: typography.xs.copyWith(color: colors.error), + ), + ), + ], + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.index, + required this.action, + required this.isDirty, + required this.expanded, + required this.onToggle, + required this.onDelete, + }); + + final int index; + final Action action; + final bool isDirty; + final bool expanded; + final VoidCallback onToggle; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = context.theme.colors; + final typography = context.theme.typography; + final meta = actionMeta(action, l10n); + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + ReorderableDragStartListener( + index: index, + child: MouseRegion( + cursor: SystemMouseCursors.grab, + child: Icon( + FLucideIcons.gripVertical, + size: 14, + color: colors.mutedForeground.withValues(alpha: 0.45), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggle, + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: colors.secondary, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + meta.icon, + size: 16, + color: colors.secondaryForeground, + ), + ), + const SizedBox(width: 12), + UnsavedLabel( + isDirty: isDirty, + child: Text( + actionRowTitle(action, l10n), + style: typography.sm.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + actionValueSummary(action, l10n), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: typography.sm.copyWith( + color: colors.mutedForeground, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + FButton.icon( + variant: .ghost, + onPress: onToggle, + child: Icon( + expanded ? FLucideIcons.chevronUp : FLucideIcons.chevronDown, + ), + ), + FButton.icon( + variant: .ghost, + onPress: onDelete, + child: const Icon(FLucideIcons.trash), + ), + ], + ), + ); + } +} + +/// Expanded editor for a single case: its condition, its action, and the +/// non-condition trigger options. Lives inside the case's [EditLocationScope] +/// so the reused editors resolve to the `branchCase*` lens family. +class _CaseExpanded extends ConsumerWidget { + const _CaseExpanded({required this.action}); + + final Action action; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final conditionsField = ref.actionField( + context, + actionConditionsLens, + fallbackValue: () => null, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConditionEditor.generic( + title: l10n.branchCase_conditionTitle, + heroTag: context.actionAddress.heroTag, + dirtyState: conditionsField.dirty, + onRevert: conditionsField.onRevert, + condition: conditionsField.value, + onConditionChanged: conditionsField.onChanged, + ), + const SizedBox(height: 16), + _caseEditor(action), + const SizedBox(height: 16), + const ActionTriggerFields( + fields: [ + ActionTriggerOptionField.triggerOn, + ActionTriggerOptionField.interval, + ActionTriggerOptionField.threshold, + ActionTriggerOptionField.limit, + ActionTriggerOptionField.conflicting, + ], + ), + ], + ); + } +} + +/// The kind-specific editor for [action]. Branches can't nest, so [OneAction] +/// renders nothing. +Widget _caseEditor(Action action) => switch (action) { + CommandAction() => const EditorCommand(), + InputAction() => const EditorInputAction(), + PlasmaShortcutAction() => const EditorPlasmaShortcut(), + ActivateWindowAction() => const EditorActivateWindow(), + ReplaceTextAction() => const EditorReplaceText(), + SleepAction() => const EditorSleep(), + FunctionAction() => const EditorFunction(), + RawAction() => const EditorRaw(), + OneAction() => const SizedBox.shrink(), +}; diff --git a/lib/ui/features/gestures/editor/state/edit_location_scope.dart b/lib/ui/features/gestures/editor/state/edit_location_scope.dart index eb1362a..ed84265 100644 --- a/lib/ui/features/gestures/editor/state/edit_location_scope.dart +++ b/lib/ui/features/gestures/editor/state/edit_location_scope.dart @@ -5,24 +5,70 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:input_actions_editor/domain/diff/dirty_semantics.dart'; import 'package:input_actions_editor/domain/edit/schema/edit_schema.dart'; import 'package:input_actions_editor/domain/edit/schema/edit_schema_extra.dart' - show actionAt, gestureAt; + show actionAt, branchCaseAt, gestureAt; import 'package:input_actions_editor/domain/edit/schema/lens.dart'; +import 'package:input_actions_editor/model/action.dart'; +import 'package:input_actions_editor/model/config.dart'; import 'package:input_actions_editor/ui/helpers/editable_field.dart'; +/// Addresses a single editable [TriggerAction], whether a top-level action or a +/// case inside a `one:` ([OneAction]) branch. Lets one row widget and the +/// scoped-field editors serve both contexts. +sealed class ActionAddress { + const ActionAddress(); + + /// The owning gesture, used as the edit scope for both kinds. + GestureLocation get gesture; + + /// Resolves the addressed action against [config]. + TriggerAction? read(Config? config); + + /// A stable, value-equal identity for this address (the underlying location), + /// suitable for hero tags and widget keys. + Object get heroTag; +} + +final class GestureActionAddress extends ActionAddress { + const GestureActionAddress(this.location); + final ActionLocation location; + @override + GestureLocation get gesture => location.gesture; + @override + TriggerAction? read(Config? config) => actionAt(config, location); + @override + Object get heroTag => location; +} + +final class BranchCaseActionAddress extends ActionAddress { + const BranchCaseActionAddress(this.location); + final BranchCaseLocation location; + @override + GestureLocation get gesture => location.action; + @override + TriggerAction? read(Config? config) => branchCaseAt(config, location); + @override + Object get heroTag => location; +} + class EditLocationScope extends InheritedWidget { const EditLocationScope({ required super.child, this.gesture, this.action, + this.branchCase, this.bulk, super.key, }) : assert( - gesture != null || action != null || bulk != null, - 'EditLocationScope requires a gesture, action, or bulk target.', + gesture != null || + action != null || + branchCase != null || + bulk != null, + 'EditLocationScope requires a gesture, action, case, or bulk target.', ); final GestureLocation? gesture; final ActionLocation? action; + final BranchCaseLocation? branchCase; /// When set, scoped field reads/writes apply across this whole selection (the /// bulk-edit page). Resolved before the single-location targets. @@ -33,7 +79,8 @@ class EditLocationScope extends InheritedWidget { static GestureLocation gestureOf(BuildContext context) { final scope = maybeOf(context); - final location = scope?.gesture ?? scope?.action?.gesture; + final location = + scope?.gesture ?? scope?.action?.gesture ?? scope?.branchCase?.action; if (location == null) { throw FlutterError( 'No GestureLocation found. Wrap this subtree in EditLocationScope.', @@ -52,18 +99,71 @@ class EditLocationScope extends InheritedWidget { return location; } + static ActionAddress actionAddressOf(BuildContext context) { + final scope = maybeOf(context); + final branchCase = scope?.branchCase; + if (branchCase != null) return BranchCaseActionAddress(branchCase); + final action = scope?.action; + if (action != null) return GestureActionAddress(action); + throw FlutterError( + 'No action address found. Wrap this subtree in EditLocationScope with an ' + 'action or branchCase target.', + ); + } + @override bool updateShouldNotify(EditLocationScope oldWidget) => gesture != oldWidget.gesture || action != oldWidget.action || + branchCase != oldWidget.branchCase || !setEquals(bulk, oldWidget.bulk); } extension EditLocationContext on BuildContext { GestureLocation get gestureLocation => EditLocationScope.gestureOf(this); ActionLocation get actionLocation => EditLocationScope.actionOf(this); + ActionAddress get actionAddress => EditLocationScope.actionAddressOf(this); } +// Identity registries mapping a top-level action field/lens to its branch-case +// counterpart. Both are generated from the same schema leaf (e.g. the `command` +// case), so the leaf editors keep passing the `action*` variant unchanged and +// [actionField]/[actionSchemaField] swap in the `branchCase*` variant when the +// scope is a branch case — mirroring how the bulk path routes through +// [EditLocationScope.bulk]. Register every action field/lens a leaf editor may +// use; the assert fires if one is missing. +final Map _branchCaseActionField = { + // Action-kind fields. + actionCommandField: branchCaseCommandField, + actionWaitField: branchCaseWaitField, + actionComponentField: branchCaseComponentField, + actionShortcutField: branchCaseShortcutField, + actionWindowIdField: branchCaseWindowIdField, + actionExpressionField: branchCaseExpressionField, + actionRawField: branchCaseRawField, + actionDurationField: branchCaseDurationField, + actionRulesField: branchCaseRulesField, + actionInputEntriesField: branchCaseInputEntriesField, + // Trigger-option fields (ActionTriggerFields). + actionIntervalField: branchCaseIntervalField, + actionThresholdField: branchCaseThresholdField, + actionLimitField: branchCaseLimitField, +}; + +final Map _branchCaseActionLens = { + // Action-kind lenses. + actionWaitLens: branchCaseWaitLens, + actionDurationLens: branchCaseDurationLens, + actionInputEntriesLens: branchCaseInputEntriesLens, + actionRulesLens: branchCaseRulesLens, + actionCommandLens: branchCaseCommandLens, + // Trigger-option + condition lenses (ActionTriggerFields, condition editor). + actionTriggerOnLens: branchCaseTriggerOnLens, + actionConflictingLens: branchCaseConflictingLens, + actionConditionsLens: branchCaseConditionsLens, + actionEnabledLens: branchCaseEnabledLens, +}; + extension ScopedFieldAccess on WidgetRef { EditableField gestureField( BuildContext context, @@ -91,14 +191,34 @@ extension ScopedFieldAccess on WidgetRef { DirtyMarkState? dirty, T Function()? fallbackValue, }) { - final location = context.actionLocation; - return this.field( - lensFor(location), - dirty: dirty, - fallbackValue: fallbackValue, - scope: location.gesture, - canRead: (config) => actionAt(config, location) != null, - ); + final addr = EditLocationScope.actionAddressOf(context); + switch (addr) { + case GestureActionAddress(:final location): + return this.field( + lensFor(location), + dirty: dirty, + fallbackValue: fallbackValue, + scope: location.gesture, + canRead: (config) => actionAt(config, location) != null, + ); + case BranchCaseActionAddress(:final location): + final branchLensFor = _branchCaseActionLens[lensFor]; + assert( + branchLensFor != null, + 'No branch-case lens registered for this action lens; add it to ' + '_branchCaseActionLens.', + ); + final lens = (branchLensFor! as Lens Function(BranchCaseLocation))( + location, + ); + return this.field( + lens, + dirty: dirty, + fallbackValue: fallbackValue, + scope: location.action, + canRead: (config) => branchCaseAt(config, location) != null, + ); + } } SchemaEditableField gestureSchemaField( @@ -125,13 +245,31 @@ extension ScopedFieldAccess on WidgetRef { GeneratedEditField> field, { DirtyMarkState? dirty, }) { - final location = context.actionLocation; - return schemaField( - field, - location: location, - dirty: dirty, - scope: location.gesture, - canRead: (config) => actionAt(config, location) != null, - ); + final addr = EditLocationScope.actionAddressOf(context); + switch (addr) { + case GestureActionAddress(:final location): + return schemaField( + field, + location: location, + dirty: dirty, + scope: location.gesture, + canRead: (config) => actionAt(config, location) != null, + ); + case BranchCaseActionAddress(:final location): + final branchField = _branchCaseActionField[field]; + assert( + branchField != null, + 'No branch-case field registered for this action field; add it to ' + '_branchCaseActionField.', + ); + return schemaField( + branchField! + as GeneratedEditField>, + location: location, + dirty: dirty, + scope: location.action, + canRead: (config) => branchCaseAt(config, location) != null, + ); + } } } diff --git a/lib/ui/features/gestures/list/gesture_list_tile.dart b/lib/ui/features/gestures/list/gesture_list_tile.dart index c9fc946..7d98fa9 100644 --- a/lib/ui/features/gestures/list/gesture_list_tile.dart +++ b/lib/ui/features/gestures/list/gesture_list_tile.dart @@ -306,6 +306,9 @@ String _firstActionSummary(TriggerCommon common, AppLocalizations l10n) { SleepAction(:final milliseconds) => 'sleep ${milliseconds}ms', FunctionAction(:final expression) => expression.trim().isEmpty ? 'function' : expression.trim(), + OneAction(:final cases) => l10n.actionMetaOneSummary( + cases.where((c) => c.conditions != null).length, + ), RawAction() => 'raw yaml', }; } diff --git a/lib/ui/features/history/history_screen.dart b/lib/ui/features/history/history_screen.dart index c084cfb..8a28fea 100644 --- a/lib/ui/features/history/history_screen.dart +++ b/lib/ui/features/history/history_screen.dart @@ -565,5 +565,8 @@ String _actionSummaryText(Action action, AppLocalizations l10n) => SleepAction(:final milliseconds) => 'sleep ${milliseconds}ms', FunctionAction(:final expression) => expression.trim().isEmpty ? 'function' : expression.trim(), + OneAction(:final cases) => l10n.actionMetaOneSummary( + cases.where((c) => c.conditions != null).length, + ), RawAction() => 'raw yaml', }; diff --git a/test/domain/optics/action_lenses_test.dart b/test/domain/optics/action_lenses_test.dart index 58f9ac6..8783b13 100644 --- a/test/domain/optics/action_lenses_test.dart +++ b/test/domain/optics/action_lenses_test.dart @@ -3,8 +3,9 @@ import 'package:input_actions_editor/domain/edit/edit_ids.dart' show assignEditIds; import 'package:input_actions_editor/domain/edit/schema/edit_schema.dart'; import 'package:input_actions_editor/domain/edit/schema/edit_schema_extra.dart' - show gestureLocationAt; + show branchCasesLens, gestureLocationAt; import 'package:input_actions_editor/model/action.dart'; +import 'package:input_actions_editor/model/condition.dart'; import 'package:input_actions_editor/model/config.dart'; import 'package:input_actions_editor/model/enums.dart'; import 'package:input_actions_editor/model/mouse_gesture.dart'; @@ -119,4 +120,70 @@ void main() { ); }); }); + + group('branch (one:) case lenses', () { + final config = assignEditIds( + const Config( + mouseGestures: [ + PressGesture( + common: TriggerCommon( + actions: [ + TriggerAction( + action: OneAction( + cases: [ + TriggerAction( + action: CommandAction(command: 'konsole-cmd'), + conditions: VariableCondition( + variable: 'window_class', + operator: '==', + value: 'konsole', + ), + ), + TriggerAction(action: CommandAction(command: 'default')), + ], + ), + ), + ], + ), + ), + ], + ), + ); + final action = ActionLocation( + gesture: gestureLocationAt(config, DeviceType.mouse, 0)!, + actionIndex: 0, + ); + BranchCaseLocation caseAt(int i) => BranchCaseLocation( + action: action.gesture, + actionIndex: 0, + caseIndex: i, + ); + + test('branchCasesLens reads the whole case list', () { + expect(branchCasesLens(action).get(config).length, 2); + }); + + test('branchCaseCommandLens reads and writes a nested case command', () { + final lens = branchCaseCommandLens(caseAt(0)); + final updated = lens.set(config, 'edited'); + + expect(lens.get(config), 'konsole-cmd'); + expect(lens.get(updated), 'edited'); + // Sibling case and the branch structure are untouched. + expect(branchCaseCommandLens(caseAt(1)).get(updated), 'default'); + expect(branchCasesLens(action).get(updated).length, 2); + }); + + test('branchCaseConditionsLens reads a nested case condition', () { + expect( + branchCaseConditionsLens(caseAt(0)).get(config), + const VariableCondition( + variable: 'window_class', + operator: '==', + value: 'konsole', + ), + ); + expect(branchCaseConditionsLens(caseAt(1)).get(config), isNull); + }); + }); } diff --git a/test/yaml_decode_test.dart b/test/yaml_decode_test.dart index f0368c3..3b8dfaa 100644 --- a/test/yaml_decode_test.dart +++ b/test/yaml_decode_test.dart @@ -704,7 +704,7 @@ mouse: ); }); - test('unmodelled action (one:) becomes a RawAction', () { + test('one: parses into a OneAction with its cases', () { final c = decodeConfig(''' touchpad: gestures: @@ -718,8 +718,10 @@ touchpad: - plasma_shortcut: kwin,Window Minimize '''); final a = c.touchpadGestures.single.common.actions.single.action; - expect(a, isA()); - expect((a as RawAction).raw, contains('plasma_shortcut')); + expect(a, isA()); + final cases = (a as OneAction).cases; + expect(cases.length, 2); + expect(cases.first.action, isA()); }); test('per-action conditions parse', () { diff --git a/test/yaml_roundtrip_test.dart b/test/yaml_roundtrip_test.dart index 4006272..5fa6983 100644 --- a/test/yaml_roundtrip_test.dart +++ b/test/yaml_roundtrip_test.dart @@ -176,6 +176,40 @@ void main() { expect(decoded.globalSettings, config.globalSettings); }); + test('a one: (first-match) action survives encode + decode', () { + const config = Config( + mouseGestures: [ + SwipeGesture( + common: TriggerCommon( + actions: [ + TriggerAction( + action: OneAction( + cases: [ + TriggerAction( + action: CommandAction(command: 'a'), + conditions: VariableCondition( + variable: 'window_class', + operator: '==', + value: 'konsole', + ), + ), + // Unconditional default ("otherwise"). + TriggerAction(action: CommandAction(command: 'b')), + ], + ), + ), + ], + ), + mode: SwipeDirectionMode(direction: SwipeDirection.up), + ), + ], + ); + + final encoded = encodeConfig(config, ''); + expect(encoded, contains('one:')); + expect(decodeConfig(encoded).mouseGestures, config.mouseGestures); + }); + test('empty config encodes and decodes back to empty', () { final decoded = decodeConfig(encodeConfig(const Config(), '')); expect(decoded.totalGestureCount, 0);