Skip to content

feat(rules)!: pinned/one-shot actions, drag-and-drop, consolidated schema - #49

Merged
hoobio merged 16 commits into
mainfrom
feat/rules-dnd-pinned-actions
Jun 1, 2026
Merged

hoobio merged 16 commits into
mainfrom
feat/rules-dnd-pinned-actions

Conversation

@hoobio

@hoobio hoobio commented May 31, 2026 •

Copy link
Copy Markdown
Owner

Reworks the rules engine and editor: implements the drag-and-drop and pinned-rules specs, plus the consolidation and duplicate asks. Done as a clean schema rewrite (no back-compat loader), since this is a new app.

Schema

  • Collapsed the redundant enum pairs into orthogonal fields: one DeviceMute action with a muted toggle, one WaveLinkMix action with Include/Exclude/Exclusive membership, and one app/default-device action with an Output/Input direction. Conditions collapse to Device / Application / DefaultDevice with a present/missing (running/not) polarity flag.
  • Added a per-action Pinned flag. Pinned actions reconcile (revert external drift, today's behaviour); one-shot actions fire once on the rule's condition-activation edge and are then left editable. The applier computes activation edges each cycle and gates every apply pass (app / default / volume / mute / Wave Link) on pinned-or-edge; reconcile passes enforce pinned only. The Devices page lock honours Pinned, so a one-shot never locks a slider.
  • Added a Default device is... condition, driven by the existing DefaultsChanged event.

Editor

  • Rebuilt conditions and actions on drag-capable lists with a live phantom slot, matching the Devices page: picking a row up collapses its slot and a make-space gap opens at the hover point, with every row gliding via a Composition Offset implicit. Reorder within a list, move actions between the Actions and Otherwise branches, and drag a row onto another rule (type-enforced - a condition can't land on an action list). Cross-rule moves rebuild the row wired to the target rule and persist both rules immediately. The rule list uses the same manual drag, so a drop only ever Moves - there is no delete-on-drag-out.
  • Section titles ("Conditions" / "Actions" / "Otherwise") and the page header are drop targets that land the row at the top, and an empty list shows a "drop here" placeholder that is itself the index-0 target - this is what lets a row be dragged into a rule that has none yet.
  • Per-row Duplicate for actions and conditions. Compact behaviour row (mode beside the Pin toggle). Section descriptions moved into info-icon tooltips.
  • Per-field match mode (Regex / Wildcard / Exact). Exact turns the field into a read-only dropdown with an in-flyout search over live devices / apps / Wave Link mixes; Regex/Wildcard stay free-text. Match chips update live while editing (debounced) and now also flag a matched device that's currently disconnected ("offline"), de-duping names where the friendly name already carries the description.
  • Shadow warnings: an action superseded by a higher-priority rule (first-match-wins) shows a caution glyph on the action and raises the rule warning; when every live action is superseded the rule dims like a no-match rule and reads "Superseded by a higher-priority rule".

Devices Page

  • Added a persisted Show rules toggle. It appears in Settings > Devices, the Devices page background right-click menu, and the ... page options menu.
  • Turning Show rules off hides the whole rules section on every device card, including the no-rules fallback text, while keeping app chips separated from the volume row by the card divider.
  • Persisted the Show disconnected devices filter so the Devices page restores that preference on relaunch.

Migration

The rules.json schema is incompatible with the old format. My local rules were migrated in place (backups kept in the Earmark data folder). A fresh install just needs a new rules.json, or re-create rules in the editor.

Validation

  • 59 Core unit tests pass (matcher conditions including default-device + polarity, app/default routing with else-branch selection, resolver first-match + Pinned, the shadow analyzer, and the per-pattern match modes).
  • Ran the app against the migrated rules: all rules load and apply with correct statuses and match summaries, pinned volume rules reconcile, no errors in the log. Verified the editor, the empty-list placeholder, and the shadow gray-out via UI Automation + window captures.
  • The drag drop/commit is best confirmed by hand (synthesised OLE drag isn't reliable to automate), so it's worth a quick manual pass.

feat(rules): warn when an action is superseded by a higher-priority rule

feat(rules): per-field match modes (regex / wildcard / exact) with a searchable exact-match picker

feat(rules): flag matched-but-disconnected devices in the match chip

test: cover rule matcher, device-rule resolver, and shadow analyzer

docs: rewrite the rule schema reference for the consolidated model

feat(devices): add persisted device-card display toggles

BREAKING-CHANGE: rules.json schema replaced - actions and conditions now use a Kind plus orthogonal mode fields (direction / membership / muted) and a Pinned flag; the old type-per-row enum no longer deserialises.

hoobio added 16 commits June 1, 2026 02:00
…d-drop

Collapse the redundant action/condition enum pairs into orthogonal fields:
one DeviceMute action with a muted toggle, one WaveLinkMix action with
include/exclude/exclusive membership, and one app/default-device action with
an output/input direction; Device/Application conditions gain a present/missing
(running/not) polarity flag. Add a "Default device is" condition driven by the
existing DefaultsChanged event.

Add a per-action Pinned flag: pinned actions are reconciled (external drift is
reverted, today's behaviour), one-shot actions fire once on the rule's
condition-activation edge and are then left editable. The applier computes
activation edges per cycle and gates every apply pass (app/default/volume/mute/
Wave Link) on pinned-or-edge; reconcile passes enforce pinned only. The Devices
page lock + reconcile honour Pinned too, so a one-shot never locks a slider.

Rebuild the Rules editor on drag-capable ListViews: reorder conditions/actions,
move actions between the Actions and Otherwise branches, and drag either onto
another rule (type-enforced - a condition can't land on an action list).
Cross-rule moves rebuild the row wired to the target rule and persist both rules
immediately. Add per-row Duplicate for actions and conditions, and slide/fade
transitions matching the Devices page.
Add coverage for the consolidated condition model (Device/Application/
DefaultDevice with the present/missing polarity flag), flow filtering,
all-conditions-must-hold, app + default-device route matching with else-branch
selection, and the resolver's first-match-wins plus Pinned threading.
…ion card

Subscribe the Rules view-model to DefaultsChanged so a "Default device is"
condition's live satisfied/status preview updates when the system default
switches, not only on topology changes. Group each action's mode control
(direction / membership / mute) beside the Pin toggle on one row so the editor
card stays compact.
…ed specs

Rewrite the AGENTS.md rule-schema section for the condition/action model
(Kind + orthogonal mode fields, the Pinned reconcile-vs-one-shot flag, and the
drag-reorder/commit-immediately behaviour), and point the routing-interface
notes at the new action kinds. Remove the rules-drag-and-drop and pinned-rules
handoff specs now that they're implemented (the drag spec described the old
ItemsControl layout).
Picking a rule, condition, or action up now opens a real make-space slot at the
hover point and the other rows glide into it, matching the Devices page: the
source slot collapses, a margin-driven gap of the same height opens at the drop
index, and every realised row carries a Composition Offset implicit so the
reflow animates. The rule list moves off the built-in CanReorderItems onto the
same manual drag, which also removes the built-in drag-out-removes-item
behaviour - every drop now only Moves or re-inserts, never deletes, so a rule
dropped outside the list just stays put.

Rename the "Pin device volume" action kind to "Set device volume": the
per-action Enforcement toggle already says Pinned vs One-shot, so the kind name
no longer needs to imply pinning.
…hotspots

The make-space gap overwrote a container's whole Margin (to insert the gap, then
reset to zero), flattening the rule list's "0,4" inter-card spacing after a drag.
Widen the existing margin instead and restore it exactly, and stop resetting the
picked-up container's margin (only its visibility was changed).

Make each section title ("Conditions" / "Actions" / "Otherwise") and the "Rules"
page header drop targets that land the dragged row at the top of their list, and
show a dashed "drop here" placeholder when a list is empty that is itself the
index-0 target. This is what lets a condition or action be dragged into a rule
that has none yet (an empty list collapses to zero height, so it offered no drop
space before).
Drop the inline caption under each "Conditions" / "Actions" / "Otherwise"
title and put the same text behind an info (i) icon beside the title, matching
the page header. Trim the redundant "the rule always runs" from the empty-
conditions placeholder (the Conditions tooltip already says leave-empty-to-run).
Add RuleShadowAnalyzer: for each rule, the active-branch actions whose target is
already claimed by an earlier (higher-priority) enabled rule, using the same
first-match-wins precedence the appliers enforce (per-app+flow, per default
flow+role, per device for volume/mute; Wave Link needs the live snapshot so it's
not analysed). The Rules editor flags each such action with a caution glyph and
raises the rule-level warning (shown collapsed and expanded). When every live
action is superseded the rule dims like a no-match rule and reads "Superseded by
a higher-priority rule".
When every live action is superseded, the rule warning now reads "All actions
are superseded by higher-priority rules and won't run"; with only some
superseded (others still active) it keeps the singular "An action is superseded
by a higher-priority rule and won't run".
…t line

Drop the redundant "Action" / "Condition" kind-combo headers (the cards are
already grouped under the section titles) and pull the self-labeling mode
controls onto the same row as the kind: an action reads "Set device for app ·
Output · Pinned" and a condition reads "Device · Present · Any", with the target
patterns beneath. The toggles' own on/off text (Output/Input, Mute/Unmute,
Pinned/One-shot) carries the meaning, so the per-field headers are gone.
Conditions now show the same match chip as actions: a Device/Default-device
condition shows the matched endpoint name, an Application condition shows the
matching-app count (computed in ConditionRow.Recompute, flow-aware).

Re-run the match preview as the user edits, not only on save or an external
audio event: RuleRow raises PreviewInvalidated on any live content change and the
view-model re-queues the (250ms-debounced) refresh, so chips, satisfied state,
and shadow flags track the typed text for dirty rules too.
Add a PatternMatchMode to every pattern field (app / device / mix on actions,
device / app on conditions), defaulting to Regex so existing rules are unchanged.
Wildcard converts globs to a "contains" regex (* = any run, ? = one char, \* / \?
= literal); Exact is a case-insensitive whole-string match (for picked items).
PatternMatcher gains a mode-aware overload and RegexCache a cached wildcard
compiler; the mode is threaded through the matcher, evaluator, resolver, shadow
analyzer, and Wave Link applier. UI to follow.
Each pattern field (app / device / mix on actions, device / app on conditions)
gets a "Match" dropdown: Regex, Wildcard, or the field's own picker (Device / App
/ Mix) for an exact match. The field's AutoSuggestBox doubles as the free-text
input and, in pick mode, the searchable list - device candidates are full display
names ("Friendly (Hardware)"), app candidates running process names, mix
candidates Wave Link mix names, all flow-filtered. The view-models match
mode-aware, round-trip the modes, and the Devices-page rule list honours them too.
…ator

- Exact-match fields now use a read-only dropdown with an in-flyout search box
  (PickerComboBox), replacing the editable combo that dropped focus while typing.
- De-dupe picker/chip names when the friendly name already ends with the device
  description (AudioEndpoint.PickerName), e.g. "Comms (Elgato Virtual Audio)" not doubled.
- Match chips now also match currently-disconnected devices and flag them "offline"
  (tooltip lists which); condition satisfaction still treats a disconnected device as absent.
@hoobio
hoobio enabled auto-merge (squash) June 1, 2026 03:01
@hoobio
hoobio merged commit 1ea0d9e into main Jun 1, 2026
13 checks passed
@hoobio
hoobio deleted the feat/rules-dnd-pinned-actions branch June 1, 2026 03:04
hoobio pushed a commit that referenced this pull request Jun 1, 2026
This PR was generated automatically by
[release-please](https://github.com/googleapis/release-please).
---


## [0.2.0](v0.1.9...v0.2.0)
(2026-06-01)


### ⚠ BREAKING CHANGES

* **rules:** pinned/one-shot actions, drag-and-drop, consolidated schema
([#49](#49))
* **devices:** rules.json schema replaced - actions and conditions now
use a Kind plus orthogonal mode fields (direction / membership / muted)
and a Pinned flag; the old type-per-row enum no longer deserialises.

### Features

* add "always show pinned apps" setting for rule-pinned chips
([dd387a6](dd387a6))
* add appearance backdrop setting (Mica, Acrylic, Solid)
([#39](#39))
([7f2769a](7f2769a))
* add Devices-page reset to default and new-install seeding
([baffea1](baffea1))
* card-height and card-divider options for the Devices page
([dd387a6](dd387a6))
* close and terminate an app from its chip menu, across all the
([dd387a6](dd387a6))
* device customisation panel (glyph + accent colour)
([#46](#46))
([34956eb](34956eb))
* Devices app chips, close/terminate, undo, and card options
([#44](#44))
([dd387a6](dd387a6))
* Devices page options menu (show title bar / show hidden / lock
([288d13a](288d13a))
* **devices:** add a Bluetooth connect/disconnect button on Bluetooth
([c6feb57](c6feb57))
* **devices:** add persisted device-card display toggles
([1ea0d9e](1ea0d9e))
* hide an app's chip on a single device from its chip context menu
([34956eb](34956eb))
* hide app chips via a context menu, manage from settings
([#41](#41))
([8e62187](8e62187))
* per-device accents, customise dialog, and Devices page options
([#47](#47))
([288d13a](288d13a))
* per-device app-chip indicators with drag-to-reroute on the Devices
([dd387a6](dd387a6))
* per-device deterministic accent colours, reworked customise
([288d13a](288d13a))
* persist disconnected devices and add the Show disconnected filter
([#48](#48))
([c6feb57](c6feb57))
* persist the navigation pane expand/collapse state across
([dd387a6](dd387a6))
* **rules:** flag matched-but-disconnected devices in the match chip
([1ea0d9e](1ea0d9e))
* **rules:** per-field match modes (regex / wildcard / exact) with a
([1ea0d9e](1ea0d9e))
* **rules:** pinned/one-shot actions, drag-and-drop, consolidated schema
([#49](#49))
([1ea0d9e](1ea0d9e))
* **rules:** warn when an action is superseded by a higher-priority rule
([1ea0d9e](1ea0d9e))
* undo app-chip hide, reorder, and group changes with Ctrl+Z
([dd387a6](dd387a6))


### Bug Fixes

* allow click-to-mute on the device icon when volume controls are
([34956eb](34956eb))
* always animate the Devices card reflow (apps row, device
([dd387a6](dd387a6))
* expanding a card's rules no longer shrinks a shorter sibling card
([dd387a6](dd387a6))
* glide app chips on re-sort by ranking, not collection Move
([dd387a6](dd387a6))
* keep the device accent tile coloured while a mute rule is active
([288d13a](288d13a))
* lay out Insights.Resource.dll for self-contained app notifications
([dd387a6](dd387a6))
* move a device directly from one group into another in one drag
([dd387a6](dd387a6))


### Documentation

* note the backdrop setting in the design-language section
([7f2769a](7f2769a))
* rewrite the rule schema reference for the consolidated model
([1ea0d9e](1ea0d9e))
* spec loopback / listening chip suppression heuristic
([#45](#45))
([16b4aad](16b4aad))


### Code Refactoring

* **devices:** split oversized DeviceCard and HomeViewModel into
([c6feb57](c6feb57))


### Tests

* cover rule matcher, device-rule resolver, and shadow analyzer
([1ea0d9e](1ea0d9e))
* **devices:** cover device-key identity, list filter, and store re-key
([c6feb57](c6feb57))


### Build System

* update dependencies to latest stable
([dd387a6](dd387a6))


### Continuous Integration

* use the release-please PR body for pre-release notes
([288d13a](288d13a))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: release-please-hoobi[bot] <279189756+release-please-hoobi[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant