Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to Chromatics are documented here.

## 4.3.29

- Added a Black layer type, available as both a base layer and a dynamic layer.
- Layers saved for a disabled or disconnected device can now be copied to another device.
- New setting under Settings → General: lower the RGB refresh rate while FFXIV is not running.
- Fixed the update prompt showing the newest version's heading twice in its changelog.

## 4.3.24

- **New:** Nanoleaf smart-light support (Beta). Covers the panel family (Shapes, Canvas, Elements, Lines, Aurora) and other controllers that speak the Nanoleaf OpenAPI. Enable it from Settings → Device Providers and pair each controller with a one-time button press; effects render across the panels at their real physical positions. Layer assignments stay with their panels if you add or remove panels from the wall later. Essentials bulbs and strips are not supported.
Expand Down
80 changes: 80 additions & 0 deletions Chromatics.Tests/Helpers/LayerCopierTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Chromatics.Helpers;
using RGB.NET.Core;

namespace Chromatics.Tests.Helpers;

// Pins the default LedId mapping used when copying layers from a device
// that is disabled or no longer connected: the layer's own LedId set
// stands in for the missing device, so these rules decide where each key
// lands on the destination before the user touches the override dropdowns.
public class LayerCopierTests
{
[Fact]
public void KeyboardPair_MapsIdentity_OnlyWhereDestinationHasTheKey()
{
var used = new[] { LedId.Keyboard_A, LedId.Keyboard_B, LedId.Keyboard_NumLock };
var destIds = new List<LedId> { LedId.Keyboard_A, LedId.Keyboard_B }; // no numpad

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, destIds);

Assert.Equal(LedId.Keyboard_A, map[LedId.Keyboard_A]);
Assert.Equal(LedId.Keyboard_B, map[LedId.Keyboard_B]);
// Keyboard pairs never fall back positionally: a missing key is
// dropped so it can't land on an unrelated physical key.
Assert.False(map.ContainsKey(LedId.Keyboard_NumLock));
}

[Fact]
public void NonKeyboard_ExactIdMatchWins()
{
var used = new[] { LedId.Custom1, LedId.Custom2 };
var destIds = new List<LedId> { LedId.Custom1, LedId.Custom2, LedId.Custom3 };

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.LedStripe, RGBDeviceType.LedStripe, destIds);

Assert.Equal(LedId.Custom1, map[LedId.Custom1]);
Assert.Equal(LedId.Custom2, map[LedId.Custom2]);
}

[Fact]
public void NonKeyboard_OrdinalFallback_WhenIdsDoNotOverlap()
{
// Source painted Custom5..Custom7; destination only has Custom1..Custom3.
var used = new[] { LedId.Custom5, LedId.Custom6, LedId.Custom7 };
var destIds = new List<LedId> { LedId.Custom1, LedId.Custom2, LedId.Custom3 };

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.LedStripe, RGBDeviceType.Mouse, destIds);

Assert.Equal(LedId.Custom1, map[LedId.Custom5]);
Assert.Equal(LedId.Custom2, map[LedId.Custom6]);
Assert.Equal(LedId.Custom3, map[LedId.Custom7]);
}

[Fact]
public void NonKeyboard_SourceLargerThanDestination_DropsTheTail()
{
var used = new[] { LedId.Custom5, LedId.Custom6, LedId.Custom7 };
var destIds = new List<LedId> { LedId.Custom1 };

var map = LayerCopier.ComputeDefaultMappingForLayer(
used, RGBDeviceType.LedStripe, RGBDeviceType.LedStripe, destIds);

Assert.Equal(LedId.Custom1, map[LedId.Custom5]);
Assert.False(map.ContainsKey(LedId.Custom6));
Assert.False(map.ContainsKey(LedId.Custom7));
}

[Fact]
public void EmptyOrMissingInputs_ReturnEmptyMap()
{
Assert.Empty(LayerCopier.ComputeDefaultMappingForLayer(
Array.Empty<LedId>(), RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, new List<LedId> { LedId.Keyboard_A }));
Assert.Empty(LayerCopier.ComputeDefaultMappingForLayer(
null, RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, new List<LedId>()));
Assert.Empty(LayerCopier.ComputeDefaultMappingForLayer(
new[] { LedId.Keyboard_A }, RGBDeviceType.Keyboard, RGBDeviceType.Keyboard, null));
}
}
62 changes: 62 additions & 0 deletions Chromatics.Tests/Helpers/UpdateNotesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Chromatics.Helpers;

namespace Chromatics.Tests.Helpers;

// Pins the release-notes trimming behind the update dialog. Notes embedded
// by older publishes start with their own version heading and carry every
// older changelog section; the dialog adds its own heading per release, so
// rendering them raw doubled the version line and repeated old sections.
public class UpdateNotesTests
{
[Fact]
public void OldFormatNotes_ReduceToOwnBullets()
{
var notes = "## 4.3.26\n\n- New feature one.\n- Fix two.\n\n## 4.3.24\n\n- Old bullet.\n";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal("- New feature one.\n- Fix two.", trimmed.Replace("\r\n", "\n"));
}

[Fact]
public void NewFormatNotes_BulletsOnly_PassThroughUnchanged()
{
var notes = "- New feature one.\n- Fix two.";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal(notes, trimmed.Replace("\r\n", "\n"));
}

[Theory]
[InlineData("## 4.3.26.0\n\n- Bullet.")]
[InlineData("## [4.3.26]\n\n- Bullet.")]
[InlineData("## v4.3.26\n\n- Bullet.")]
[InlineData("## 4.3.26 - 2026-07-07\n\n- Bullet.")]
public void HeadingVariants_AreAllStripped(string notes)
{
var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal("- Bullet.", trimmed);
}

[Fact]
public void DifferentVersionHeading_IsNotStripped_ButLaterSectionsAreCut()
{
// A mislabelled asset should not lose its first line; only the
// trailing sections get cut.
var notes = "- Bullet without heading.\n\n## 4.3.20\n\n- Old.";

var trimmed = UpdateService.TrimNotesToOwnSection(notes, "4.3.26");

Assert.Equal("- Bullet without heading.", trimmed.Replace("\r\n", "\n"));
}

[Fact]
public void EmptyOrWhitespaceNotes_ReturnEmpty()
{
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection("", "4.3.26"));
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection(" ", "4.3.26"));
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection(null, "4.3.26"));

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2022)

Cannot convert null literal to non-nullable reference type.

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2022)

Cannot convert null literal to non-nullable reference type.

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2025)

Cannot convert null literal to non-nullable reference type.

Check warning on line 60 in Chromatics.Tests/Helpers/UpdateNotesTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-2025)

Cannot convert null literal to non-nullable reference type.
}
}
2 changes: 1 addition & 1 deletion Chromatics/Chromatics.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<StartupObject>Chromatics.Program</StartupObject>
<Version>4.3.24.0</Version>
<Version>4.3.29.0</Version>
<Authors>Danielle Thompson</Authors>
<!-- ApplicationManifest is conditional: local Debug + Release builds embed
app.manifest (no fusion-identity <msix> element) so VS debug runs and
Expand Down
33 changes: 25 additions & 8 deletions Chromatics/Core/RGBController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,27 @@ private static void LogLogitechSdkHintIfNeeded(Exception ex)

private const double IdleUpdateFrequency = 0.05; // 20 Hz

private static void SetIdleUpdateRate(bool idle)
// Sets the surface tick rate from live state: the idle rate applies
// only while the setting is on AND the game is genuinely detached.
// Derived from GameController.IsGameConnected rather than a flag the
// callers pass, because RunStartupEffects (which used to pass
// idle:true) also runs on device hot-plug and re-enable while the
// game is connected - trusting the flag throttled mid-game. With
// the setting off, idle is always false, so the configured rate
// runs in every state.
public static void ApplyUpdateRate()
{
if (_timerUpdateTrigger == null) return;
_timerUpdateTrigger.UpdateFrequency = idle
? IdleUpdateFrequency
: AppSettings.GetSettings().rgbRefreshRate;

var settings = AppSettings.GetSettings();
bool idle = settings.idleRefreshWhenDisconnected && !GameController.IsGameConnected();
_timerUpdateTrigger.UpdateFrequency = idle ? IdleUpdateFrequency : settings.rgbRefreshRate;
}

// Called when the user flips the idle-refresh setting so the change
// lands immediately instead of on the next connect or disconnect.
public static void ReapplyIdleUpdateRate() => ApplyUpdateRate();

// Runs one provider's load block and turns assembly-load faults into
// console guidance instead of a startup crash. Windows App Control
// (Smart App Control / WDAC) blocks the unsigned vendor DLLs on some
Expand Down Expand Up @@ -1686,9 +1699,11 @@ private static void BuildTitleEffectForDevice(IRGBDevice device, Guid deviceGuid

public static void RunStartupEffects()
{
// Drop the surface to the idle tick rate whether or not the startup
// animation is enabled — nothing game-driven is running either way.
SetIdleUpdateRate(true);
// Re-evaluate the surface tick rate. ApplyUpdateRate reads the
// live connection state, so this is a no-op when the game is
// already attached (device re-enable / hot-plug mid-game) and
// only idles when genuinely disconnected with the setting on.
ApplyUpdateRate();

if (!_effects.effect_startupanimation) return;

Expand Down Expand Up @@ -1745,7 +1760,9 @@ public static void StopEffects(bool gameFirstConnected = false)
{
if (gameFirstConnected)
{
SetIdleUpdateRate(false);
// Game just attached (gameConnected is already true here),
// so this restores the configured rate.
ApplyUpdateRate();
}

List<ListLedGroup> snapshot;
Expand Down
6 changes: 5 additions & 1 deletion Chromatics/Enums/LayerTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public enum BaseLayerType
ScreenCapture = 4,
[LayerDisplay(Name = "Audio Visualizer (Beta)", Description = "Spectrum analyser base layer that reacts to game audio output. Displays frequency bars across keyboard columns.", LayerTypeCompatibility = new LayerModes[]{ LayerModes.None })]
AudioVisualizer = 5,
[LayerDisplay(Name = "Black", Description = "Sets the entire base layer to black. Keys stay dark unless a higher layer paints over them.", LayerTypeCompatibility = new LayerModes[]{ LayerModes.None })]
Black = 6,
};
public enum DynamicLayerType
{
Expand Down Expand Up @@ -73,7 +75,9 @@ public enum DynamicLayerType
[LayerDisplay(Name = "Focus Target HP", Description = "Shows your focus target's HP across selected keys.\n[Supported Modes: Interpolate, Fade]", LayerTypeCompatibility = new LayerModes[]{ LayerModes.Interpolate, LayerModes.Fade })]
FocusTargetHP = 16,
[LayerDisplay(Name = "Focus Target Castbar", Description = "Shows your focus target's cast progress across selected keys.\n[Supported Modes: Interpolate, Fade]", LayerTypeCompatibility = new LayerModes[]{ LayerModes.Interpolate, LayerModes.Fade })]
FocusTargetCastbar = 17
FocusTargetCastbar = 17,
[LayerDisplay(Name = "Black", Description = "Sets the selected keys to black.", LayerTypeCompatibility = new LayerModes[]{ LayerModes.None })]
Black = 18
};
public enum EffectLayerType
{
Expand Down
4 changes: 2 additions & 2 deletions Chromatics/Helpers/FileOperationsHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ public static void SaveLayerMappings(ConcurrentDictionary<int, Layer> mappings,

var wrapper = new MappingFileV3
{
schemaVersion = 7,
schemaVersion = 8,
layers = layersSnapshot,
deviceLayouts = layoutsSnapshot,
deviceBrightness = brightnessSnapshot,
Expand Down Expand Up @@ -644,7 +644,7 @@ public static void ExportLayerMappingsToPath(ConcurrentDictionary<int, Layer> la

var wrapper = new MappingFileV3
{
schemaVersion = 7,
schemaVersion = 8,
layers = layersCopy,
deviceLayouts = deviceLayouts != null
? new Dictionary<Guid, Dictionary<RGB.NET.Core.LedId, DeviceKeyPosition>>(deviceLayouts)
Expand Down
63 changes: 63 additions & 0 deletions Chromatics/Helpers/LayerCopier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,69 @@ public static Dictionary<LedId, LedId> ComputeDefaultMappingForLayer(
return map;
}

// Overload for a source device that is disabled or no longer
// connected: its layers survive in layers.chromatics4 but there is
// no IRGBDevice to enumerate. The layer's own LedId set stands in
// for the device - identity match where the destination has the
// same LedId, ordinal fallback otherwise (Nth used source LedId ->
// Nth destination LED). The user can correct any row from the
// per-layer mapping expander as usual.
public static Dictionary<LedId, LedId> ComputeDefaultMappingForLayer(
IReadOnlyCollection<LedId> usedSourceLedIds,
RGBDeviceType sourceType,
IRGBDevice dest)
{
if (usedSourceLedIds == null || dest == null) return new Dictionary<LedId, LedId>();

return ComputeDefaultMappingForLayer(
usedSourceLedIds,
sourceType,
dest.DeviceInfo.DeviceType,
dest.OrderBy(l => (int)l.Id).Select(l => l.Id).ToList());
}

// Device-free core so the mapping rules stay unit-testable -
// IRGBDevice takes a full device stack to construct, which keeps
// the rules out of reach of plain xUnit otherwise.
public static Dictionary<LedId, LedId> ComputeDefaultMappingForLayer(
IReadOnlyCollection<LedId> usedSourceLedIds,
RGBDeviceType sourceType,
RGBDeviceType destType,
IReadOnlyList<LedId> destLedIdsOrdered)
{
var map = new Dictionary<LedId, LedId>();
if (usedSourceLedIds == null || destLedIdsOrdered == null) return map;
if (usedSourceLedIds.Count == 0) return map;

var destIds = new HashSet<LedId>(destLedIdsOrdered);

bool keyboardPair = sourceType == RGBDeviceType.Keyboard
&& destType == RGBDeviceType.Keyboard;

if (keyboardPair)
{
foreach (var ledId in usedSourceLedIds)
if (destIds.Contains(ledId))
map[ledId] = ledId;
return map;
}

var orderedSource = usedSourceLedIds.OrderBy(id => (int)id).ToList();
int n = Math.Min(orderedSource.Count, destLedIdsOrdered.Count);
var positionalFallback = new Dictionary<LedId, LedId>();
for (int i = 0; i < n; i++)
positionalFallback[orderedSource[i]] = destLedIdsOrdered[i];

foreach (var ledId in usedSourceLedIds)
{
if (destIds.Contains(ledId))
map[ledId] = ledId;
else if (positionalFallback.TryGetValue(ledId, out var fallback))
map[ledId] = fallback;
}
return map;
}

// One copy operation. The view-model passes a list of these
// into Apply; each represents "copy this source layer onto
// the destination with this LedId mapping".
Expand Down
26 changes: 26 additions & 0 deletions Chromatics/Helpers/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ public static class UpdateService
private const string StableFeedUrl = "https://chromaticsffxiv.com/chromatics4/update/stable/";
private const string BetaFeedUrl = "https://chromaticsffxiv.com/chromatics4/update/beta/";

// The update dialog prepends its own "## {version}" heading per
// release, but notes embedded by older publishes start with the
// version's changelog heading themselves and run to the end of
// CHANGELOG.md - rendering both doubled the version line and
// stacked older sections into every entry. Trim each release's
// notes to just its own bullets: drop a leading heading that names
// this version (any of the changelog heading shapes, 4-part
// tolerated), then cut at the next "## " section. New publishes
// embed bullets only, so this is a no-op for them.
public static string TrimNotesToOwnSection(string notes, string version)
{
if (string.IsNullOrWhiteSpace(notes)) return string.Empty;
var text = notes.Trim();

var ownHeading = new System.Text.RegularExpressions.Regex(
@"^##\s+\[?v?" + System.Text.RegularExpressions.Regex.Escape(version) + @"(\.0)?\]?[^\n]*\r?\n?");
text = ownHeading.Replace(text, string.Empty, 1).TrimStart();

var nextSection = System.Text.RegularExpressions.Regex.Match(
text, @"^##\s+\S", System.Text.RegularExpressions.RegexOptions.Multiline);
if (nextSection.Success)
text = text[..nextSection.Index].TrimEnd();

return text;
}

// Marker file that ships inside the nupkg for real beta builds only.
// Stable releases (including any a beta install migrates to) never have it,
// so after a beta→stable migration the title drops the [BETA] suffix cleanly.
Expand Down
Loading
Loading