Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ env:
BOM_FILE: sbom.cdx.json
# NOTE: GitHub Actions does not allow expressions in `uses:` refs, so the
# pipeline-tools pin is repeated in each `uses:` line below. Bump in lockstep.
# Current pin: hoobio/pipeline-tools @ v1.5.0 (adds Backstage hierarchy bootstrap
# and channel routing for Dependency-Track SBOM uploads).
# Current pin: hoobio/pipeline-tools @ v2.2.1 (v2 split the Dependency-Track
# channel input into channel + sub-channel; see the SBOM job below).

jobs:
release-please:
Expand Down Expand Up @@ -286,9 +286,13 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_SDK_VERSION }}

# Channel routing for the Backstage hierarchy in Dependency-Track:
# release event -> channel=release, mark-latest=true, no prune
# push / dispatch / PR -> channel=ci/<branch>,mark-latest=false, prune
# Channel routing for the Backstage hierarchy in Dependency-Track. v2 of
# pipeline-tools takes channel + sub-channel as two separate inputs (the
# old single `ci/<branch>` string is a v1 shape that the bootstrap now
# treats as a legacy and migrates out from under us).
# release event -> channel=release, sub-channel='', mark-latest=true, no prune
# push to default branch -> channel=main, sub-channel='', mark-latest=false, prune
# push to feature / PR -> channel=ci, sub-channel=<branch>, mark-latest=false, prune
- name: Resolve DT channel
id: dt
shell: pwsh
Expand All @@ -297,30 +301,48 @@ jobs:
REF_NAME: ${{ github.ref_name }}
HEAD_REF: ${{ github.head_ref }}
SHA: ${{ github.sha }}
DEFAULT_BRANCH: main
run: |
if ($env:EVENT_NAME -eq 'release') {
$channel = 'release'
$subChannel = ''
$projectVersion = $env:REF_NAME
$markLatest = 'true'
$prune = 'false'
}
else {
$branchSource = if ($env:EVENT_NAME -eq 'pull_request') { $env:HEAD_REF } else { $env:REF_NAME }
$sanitized = ($branchSource -replace '[^A-Za-z0-9._/-]', '-')
$channel = "ci/$sanitized"
if ($sanitized -eq $env:DEFAULT_BRANCH) {
# Default branch sits at top-level alongside release, not nested under ci.
$channel = $sanitized
$subChannel = ''
}
else {
$channel = 'ci'
$subChannel = $sanitized
}
$projectVersion = $env:SHA
$markLatest = 'false'
$prune = 'true'
}

"channel=$channel" | Out-File -Append $env:GITHUB_OUTPUT
"project_version=$projectVersion" | Out-File -Append $env:GITHUB_OUTPUT
"mark_latest=$markLatest" | Out-File -Append $env:GITHUB_OUTPUT
"prune=$prune" | Out-File -Append $env:GITHUB_OUTPUT
Write-Host "Channel: $channel"
Write-Host "ProjectVersion: $projectVersion"
Write-Host "MarkLatest: $markLatest"
Write-Host "Prune: $prune"
# Effective parent name used by the upload step. Used for the tag value
# so DT filters match the new v2 hierarchy.
$effectiveChannel = if ($subChannel) { $subChannel } else { $channel }

"channel=$channel" | Out-File -Append $env:GITHUB_OUTPUT
"sub_channel=$subChannel" | Out-File -Append $env:GITHUB_OUTPUT
"effective_channel=$effectiveChannel" | Out-File -Append $env:GITHUB_OUTPUT
"project_version=$projectVersion" | Out-File -Append $env:GITHUB_OUTPUT
"mark_latest=$markLatest" | Out-File -Append $env:GITHUB_OUTPUT
"prune=$prune" | Out-File -Append $env:GITHUB_OUTPUT
Write-Host "Channel: $channel"
Write-Host "SubChannel: $subChannel"
Write-Host "EffectiveChannel: $effectiveChannel"
Write-Host "ProjectVersion: $projectVersion"
Write-Host "MarkLatest: $markLatest"
Write-Host "Prune: $prune"

- name: Generate CycloneDX SBOM
uses: hoobio/pipeline-tools/pipeline/github/step/cyclonedx-sbom-dotnet@v2.2.1
Expand All @@ -347,8 +369,10 @@ jobs:
system: ${{ env.DT_SYSTEM }}
component: ${{ env.DT_COMPONENT }}
channel: ${{ steps.dt.outputs.channel }}
sub-channel: ${{ steps.dt.outputs.sub_channel }}
default-branch: main
project-version: ${{ steps.dt.outputs.project_version }}
project-tags: channel=${{ steps.dt.outputs.channel }},repo=${{ github.repository }},commit=${{ github.sha }},run_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
project-tags: channel=${{ steps.dt.outputs.effective_channel }},repo=${{ github.repository }},commit=${{ github.sha }},run_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
mark-latest: ${{ steps.dt.outputs.mark_latest }}
prune-stale-children: ${{ steps.dt.outputs.prune }}
keep: ${{ github.event_name == 'workflow_dispatch' && '1' || '10' }}
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md → AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ Rules apply in **list order** - top of the list wins. There is no priority field
- `RuleRow` is the per-rule view-model. It owns its own debounced `SaveAsync`. When the underlying rules list changes, `RulesViewModel.OnRulesChanged` calls `SyncFromRule` on each existing row in place (preserving expanded state) when the order is unchanged; only on add/delete/reorder does it rebuild `Items`.
- Tray: `H.NotifyIcon.WinUI`. `WindowChromeManager` subclasses the window with `SetWindowSubclass` to intercept `WM_SYSCOMMAND/SC_MINIMIZE` for "minimize to tray", and handles `Closed` for "close to tray".

## Reactivity preferences

- **Event-driven first, polling as fallback.** When the OS exposes a change notification (e.g. `IMMNotificationClient`, `AudioEndpointVolume.OnVolumeNotification`, `IAudioSessionEvents`), prefer subscribing to that and reconciling on the event. Polling is acceptable as a safety net (drift recovery, restart correctness), but the primary path for "external state changed -> reconcile our state" must not depend on a tick interval. New code that adds reactivity should plumb the event path first and only fall back to polling when the OS has no usable notification.

## Common gotchas

- **Don't use `[InterfaceType(InterfaceIsIInspectable)]`** in COM interop. Modern .NET doesn't marshal it. Use `IUnknown` with reserved vtable slots.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Per-app audio routing for Windows, driven by regex rules. Pin a browser to your
[![GitHub Issues](https://img.shields.io/github/issues/hoobio/earmark)](https://github.com/hoobio/earmark/issues)
[![Last Commit](https://img.shields.io/github/last-commit/hoobio/earmark)](https://github.com/hoobio/earmark/commits/main)

![Earmark](application.png)
![Devices](ss_application.png) ![Rules Engine](ss_rules.png)

## Features

Expand Down
Binary file removed application.png
Binary file not shown.
23 changes: 17 additions & 6 deletions src/Earmark.App/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
<converters:WaveLinkStateBrushConverter x:Key="WaveLinkStateBrushConverter" />
<converters:WaveLinkStateGlyphConverter x:Key="WaveLinkStateGlyphConverter" />
<converters:VolumeFloatToPercentConverter x:Key="VolumeFloatToPercentConverter" />
<converters:MutedBrushConverter x:Key="MutedBrushConverter" />
<converters:MuteLockedBackgroundConverter x:Key="MuteLockedBackgroundConverter" />
<converters:PeakLevelToBrushConverter x:Key="PeakLevelToBrushConverter" />

<!-- Fluent 2 spacing scale (4px grid). -->
<x:Double x:Key="SpacingXSmall">4</x:Double>
Expand Down Expand Up @@ -63,16 +66,14 @@

<Style x:Key="SectionCardStyle" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource LayerFillColorDefaultBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="{StaticResource SectionPadding}" />
</Style>

<Style x:Key="SubtleCardStyle" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource SubtleFillColorTertiaryBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource ControlStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="{StaticResource SubtleCardPadding}" />
</Style>
Expand All @@ -84,8 +85,7 @@

<Style x:Key="ChipBorderStyle" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource ControlFillColorTertiaryBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource ControlStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="8,2" />
</Style>
Expand All @@ -103,6 +103,17 @@
<Setter Property="Padding" Value="6" />
<Setter Property="MinWidth" Value="32" />
</Style>

<!-- Subtle, full-width clickable chip used for rule summaries on the Devices page.
No border - background colour alone carries the chip. -->
<Style x:Key="SubtleRuleChipButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource SubtleFillColorTertiaryBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="{StaticResource SubtleCardPadding}" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
1 change: 1 addition & 0 deletions src/Earmark.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
await _host.Services.GetRequiredService<ISettingsService>().LoadAsync();
_host.Services.GetRequiredService<StartupSettingsApplier>().Start();
_host.Services.GetRequiredService<INotificationService>().Register();
_logger.LogInformation("Settings loaded");

// Heavy init (audio endpoint + session enumeration via COM, rules load, routing
Expand Down
190 changes: 190 additions & 0 deletions src/Earmark.App/Controls/WrapByRowLayout.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

using Windows.Foundation;

namespace Earmark.App.Controls;

/// <summary>
/// Wrap-style virtualised layout. Arranges items into rows of equal-width columns; each row's
/// height is sized to the tallest card in that row alone, independent of other rows. Unlike
/// <see cref="UniformGridLayout"/> (which makes all items the same height), one expanded card
/// only grows the row it sits in - it does not balloon every card on the page.
/// </summary>
public sealed class WrapByRowLayout : VirtualizingLayout
{
/// <summary>
/// Attached property. When true on an item, the item keeps its own desired height
/// instead of stretching to the row baseline - lets one user-expanded card grow on its
/// own while sibling cards in the row stay aligned to each other.
/// </summary>
public static readonly DependencyProperty IsCustomSizedProperty = DependencyProperty.RegisterAttached(
"IsCustomSized", typeof(bool), typeof(WrapByRowLayout),
new PropertyMetadata(false, OnIsCustomSizedChanged));

public static bool GetIsCustomSized(DependencyObject element) =>
(bool)element.GetValue(IsCustomSizedProperty);

public static void SetIsCustomSized(DependencyObject element, bool value) =>
element.SetValue(IsCustomSizedProperty, value);

private static void OnIsCustomSizedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is UIElement element)
{
// Force the parent ItemsRepeater to remeasure when a card flips between
// baseline-sized and custom-sized.
element.InvalidateMeasure();
}
}

public static readonly DependencyProperty MinItemWidthProperty = DependencyProperty.Register(
nameof(MinItemWidth), typeof(double), typeof(WrapByRowLayout),
new PropertyMetadata(320.0, OnLayoutPropertyChanged));

public static readonly DependencyProperty ColumnSpacingProperty = DependencyProperty.Register(
nameof(ColumnSpacing), typeof(double), typeof(WrapByRowLayout),
new PropertyMetadata(12.0, OnLayoutPropertyChanged));

public static readonly DependencyProperty RowSpacingProperty = DependencyProperty.Register(
nameof(RowSpacing), typeof(double), typeof(WrapByRowLayout),
new PropertyMetadata(12.0, OnLayoutPropertyChanged));

public double MinItemWidth
{
get => (double)GetValue(MinItemWidthProperty);
set => SetValue(MinItemWidthProperty, value);
}

public double ColumnSpacing
{
get => (double)GetValue(ColumnSpacingProperty);
set => SetValue(ColumnSpacingProperty, value);
}

public double RowSpacing
{
get => (double)GetValue(RowSpacingProperty);
set => SetValue(RowSpacingProperty, value);
}

private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WrapByRowLayout layout)
{
layout.InvalidateMeasure();
}
}

protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize)
{
if (context.ItemCount == 0)
{
return new Size(availableSize.Width, 0);
}

var columnCount = ComputeColumnCount(availableSize.Width);
var columnWidth = ComputeColumnWidth(availableSize.Width, columnCount);

var totalHeight = 0.0;
var rowMax = 0.0;
var inRow = 0;

for (var i = 0; i < context.ItemCount; i++)
{
var element = context.GetOrCreateElementAt(i);
element.Measure(new Size(columnWidth, double.PositiveInfinity));

if (element.DesiredSize.Height > rowMax)
{
rowMax = element.DesiredSize.Height;
}
inRow++;

if (inRow == columnCount)
{
totalHeight += rowMax;
if (i < context.ItemCount - 1) totalHeight += RowSpacing;
rowMax = 0;
inRow = 0;
}
}

if (inRow > 0)
{
totalHeight += rowMax;
}

return new Size(availableSize.Width, totalHeight);
}

protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize)
{
if (context.ItemCount == 0)
{
return new Size(finalSize.Width, 0);
}

var columnCount = ComputeColumnCount(finalSize.Width);
var columnWidth = ComputeColumnWidth(finalSize.Width, columnCount);

var y = 0.0;

for (var rowStart = 0; rowStart < context.ItemCount; rowStart += columnCount)
{
var rowEnd = Math.Min(rowStart + columnCount, context.ItemCount);

// Pass 1: baseline = tallest non-custom card. Custom-sized cards (e.g. a card with
// its rules-chevron expanded by the user) are excluded so they don't drag everyone
// else up. Also track overall row height for next-row positioning.
var baseline = 0.0;
var rowTotal = 0.0;
for (var i = rowStart; i < rowEnd; i++)
{
var element = context.GetOrCreateElementAt(i);
var h = element.DesiredSize.Height;
if (h > rowTotal) rowTotal = h;
if (!GetIsCustomSized((DependencyObject)element) && h > baseline)
{
baseline = h;
}
}
if (baseline == 0) baseline = rowTotal; // all custom-sized? fall back to true max

// Pass 2: non-custom cards stretch to the baseline so siblings stay aligned.
// Custom-sized cards keep their own (taller) desired height.
for (var i = rowStart; i < rowEnd; i++)
{
var element = context.GetOrCreateElementAt(i);
var col = i - rowStart;
var x = col * (columnWidth + ColumnSpacing);
var h = GetIsCustomSized((DependencyObject)element)
? element.DesiredSize.Height
: baseline;
element.Arrange(new Rect(x, y, columnWidth, h));
}

y += rowTotal;
if (rowEnd < context.ItemCount)
{
y += RowSpacing;
}
}

return new Size(finalSize.Width, y);
}

private int ComputeColumnCount(double availableWidth)
{
if (availableWidth <= 0 || MinItemWidth <= 0) return 1;
var candidate = (availableWidth + ColumnSpacing) / (MinItemWidth + ColumnSpacing);
return Math.Max(1, (int)Math.Floor(candidate));
}

private double ComputeColumnWidth(double availableWidth, int columnCount)
{
if (columnCount <= 0) return availableWidth;
var totalSpacing = ColumnSpacing * (columnCount - 1);
return Math.Max(0, (availableWidth - totalSpacing) / columnCount);
}
}
Loading
Loading