diff --git a/YKanBan.Tests/Models/RgbColorTests.cs b/YKanBan.Tests/Models/RgbColorTests.cs
new file mode 100644
index 0000000..a37fd42
--- /dev/null
+++ b/YKanBan.Tests/Models/RgbColorTests.cs
@@ -0,0 +1,72 @@
+using YKanBan.Models;
+
+namespace YKanBan.Tests.Models;
+
+/// Tests for the RGB color value type and its #RRGGBB text conversions.
+[TestClass]
+public class RgbColorTests
+{
+ [TestMethod]
+ public void ParseExtractsComponents()
+ {
+ RgbColor color = RgbColor.Parse("#ff0a00");
+ Assert.AreEqual(new RgbColor(0xFF, 0x0A, 0x00), color);
+ }
+
+ [TestMethod]
+ public void ParseAcceptsUppercaseAndMixedCaseHex()
+ {
+ Assert.AreEqual(new RgbColor(0xAB, 0xCD, 0xEF), RgbColor.Parse("#ABCDEF"));
+ Assert.AreEqual(new RgbColor(0xAB, 0xCD, 0xEF), RgbColor.Parse("#aBcDeF"));
+ }
+
+ [TestMethod]
+ [DataRow("ff0000")]
+ [DataRow("#ff000")]
+ [DataRow("#ff00000")]
+ [DataRow("#gg0000")]
+ [DataRow("#ff 000")]
+ [DataRow(" #ff0000")]
+ [DataRow("#ff0000 ")]
+ [DataRow("#")]
+ [DataRow("")]
+ public void TryParseRejectsMalformedText(string text)
+ {
+ Assert.IsFalse(RgbColor.TryParse(text, out _));
+ }
+
+ [TestMethod]
+ public void TryParseRejectsNull()
+ {
+ Assert.IsFalse(RgbColor.TryParse(null, out _));
+ }
+
+ [TestMethod]
+ public void ParseThrowsFormatExceptionForMalformedText()
+ {
+ Assert.ThrowsExactly(() => RgbColor.Parse("red"));
+ }
+
+ [TestMethod]
+ public void ToStringProducesCanonicalLowercaseForm()
+ {
+ Assert.AreEqual("#ee0a0a", new RgbColor(0xEE, 0x0A, 0x0A).ToString());
+
+ // Uppercase input normalizes to the canonical lowercase output.
+ Assert.AreEqual("#ee0a0a", RgbColor.Parse("#EE0A0A").ToString());
+ }
+
+ [TestMethod]
+ public void EqualityComparesComponents()
+ {
+ Assert.AreEqual(RgbColor.Parse("#ff0000"), RgbColor.Parse("#FF0000"));
+ Assert.AreNotEqual(RgbColor.Parse("#ff0000"), RgbColor.Parse("#ff0001"));
+ }
+
+ [TestMethod]
+ public void TextRoundTripPreservesColor()
+ {
+ RgbColor original = new(0x12, 0x34, 0x56);
+ Assert.AreEqual(original, RgbColor.Parse(original.ToString()));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs
index 1582980..5981d83 100644
--- a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs
@@ -235,7 +235,7 @@ public class WorkspaceRepositoryTests
using var fixture = new RepositoryFixture();
fixture.Clock = 2000;
- TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", "#ff0000", "");
+ TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", RgbColor.Parse("#ff0000"), "");
Assert.AreEqual("bug", result.Name);
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
@@ -247,10 +247,10 @@ public class WorkspaceRepositoryTests
using var fixture = new RepositoryFixture();
fixture.Clock = 3000;
- TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", "#ee0a0a", "desc");
+ TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", RgbColor.Parse("#ee0a0a"), "desc");
Assert.AreEqual("defect", updated.Name);
- Assert.AreEqual("#ee0a0a", updated.Color);
+ Assert.AreEqual(new RgbColor(0xEE, 0x0A, 0x0A), updated.Color);
Assert.AreEqual("desc", updated.Description);
Assert.AreEqual(3000L, fixture.LastEditedAt);
}
diff --git a/YKanBan/Models/RgbColor.cs b/YKanBan/Models/RgbColor.cs
new file mode 100644
index 0000000..9644bee
--- /dev/null
+++ b/YKanBan/Models/RgbColor.cs
@@ -0,0 +1,63 @@
+namespace YKanBan.Models;
+
+///
+/// An RGB color without an alpha channel, stored as one byte per component
+/// — mirroring the color structures of WinForms, WPF and Avalonia minus
+/// the alpha byte. The type is total: every combination of components is
+/// valid, and all validation happens at the text boundary. Converts to and
+/// from the persisted "#RRGGBB" text form; ToString always produces the
+/// canonical lowercase form (e.g. "#ff0a00").
+///
+public readonly record struct RgbColor(byte R, byte G, byte B)
+{
+ /// Parses a "#RRGGBB" text with case-insensitive hex digits.
+ /// The text is not in #RRGGBB form.
+ public static RgbColor Parse(string text) =>
+ TryParse(text, out RgbColor color)
+ ? color
+ : throw new FormatException($"Not a valid #RRGGBB color: '{text}'.");
+
+ /// Attempts to parse a "#RRGGBB" text; no whitespace tolerance.
+ public static bool TryParse(string? text, out RgbColor color)
+ {
+ color = default;
+ if (text is not { Length: 7 } || text[0] != '#')
+ {
+ return false;
+ }
+
+ // Two hex digits per component: R, G, B.
+ if (TryHexPair(text, 1, out byte r) && TryHexPair(text, 3, out byte g) && TryHexPair(text, 5, out byte b))
+ {
+ color = new RgbColor(r, g, b);
+ return true;
+ }
+ return false;
+ }
+
+ /// Returns the canonical lowercase "#RRGGBB" form used by the database and exports.
+ public override string ToString() => $"#{R:x2}{G:x2}{B:x2}";
+
+ /// Parses exactly two hex digits into a byte value.
+ private static bool TryHexPair(string text, int start, out byte value)
+ {
+ value = 0;
+ int high = HexDigit(text[start]);
+ int low = HexDigit(text[start + 1]);
+ if (high < 0 || low < 0)
+ {
+ return false;
+ }
+ value = (byte)((high << 4) | low);
+ return true;
+ }
+
+ /// Converts one hex digit to its value; -1 for any other character.
+ private static int HexDigit(char character) => character switch
+ {
+ >= '0' and <= '9' => character - '0',
+ >= 'a' and <= 'f' => character - 'a' + 10,
+ >= 'A' and <= 'F' => character - 'A' + 10,
+ _ => -1,
+ };
+}
diff --git a/YKanBan/Models/TagModel.cs b/YKanBan/Models/TagModel.cs
index c124ce6..b849bf5 100644
--- a/YKanBan/Models/TagModel.cs
+++ b/YKanBan/Models/TagModel.cs
@@ -3,6 +3,6 @@ namespace YKanBan.Models;
/// A tag assignable to cards. Tags are never garbage-collected: unreferenced tags stay until deleted explicitly.
/// Row id.
/// Arbitrary non-empty text; no charset or length rules.
-/// Fixed #RRGGBB form.
+/// The tag color; persisted as #RRGGBB text.
/// Free-form description, possibly empty.
-public sealed record TagModel(long Id, string Name, string Color, string Description);
+public sealed record TagModel(long Id, string Name, RgbColor Color, string Description);
diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs
index 565e53d..3dbd4fa 100644
--- a/YKanBan/Storage/Workspace/WorkspaceRepository.cs
+++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs
@@ -145,7 +145,7 @@ public sealed class WorkspaceRepository : IDisposable
}
if (!reader.IsDBNull(6))
{
- tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9)));
+ tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), RgbColor.Parse(reader.GetString(8)), reader.GetString(9)));
}
}
return cards;
@@ -183,7 +183,7 @@ public sealed class WorkspaceRepository : IDisposable
}
if (!reader.IsDBNull(6))
{
- tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9)));
+ tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), RgbColor.Parse(reader.GetString(8)), reader.GetString(9)));
}
}
return card;
@@ -313,13 +313,13 @@ public sealed class WorkspaceRepository : IDisposable
}
/// Creates a tag.
- /// The name is empty/not unique, or the color is not #RRGGBB.
- public TagModel AddTag(string name, string color, string description)
+ /// The name is empty/not unique.
+ public TagModel AddTag(string name, RgbColor color, string description)
{
using var transaction = _connection.BeginTransaction();
long id = ExecuteInsert(transaction,
"INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);",
- ("$name", name), ("$color", color), ("$description", description));
+ ("$name", name), ("$color", color.ToString()), ("$description", description));
transaction.Commit();
TouchEdited();
@@ -328,8 +328,8 @@ public sealed class WorkspaceRepository : IDisposable
/// Rewrites name, color and description; a no-change call writes nothing.
/// The tag does not exist.
- /// The new name is empty/not unique, or the color is not #RRGGBB.
- public TagModel UpdateTag(long tagId, string name, string color, string description)
+ /// The new name is empty/not unique.
+ public TagModel UpdateTag(long tagId, string name, RgbColor color, string description)
{
TagModel existing = GetTag(tagId)
?? throw new InvalidOperationException($"Tag {tagId} does not exist.");
@@ -343,7 +343,7 @@ public sealed class WorkspaceRepository : IDisposable
using var command = CreateCommand(
"UPDATE tags SET name = $name, color = $color, description = $description WHERE id = $id;");
command.Parameters.AddWithValue("$name", name);
- command.Parameters.AddWithValue("$color", color);
+ command.Parameters.AddWithValue("$color", color.ToString());
command.Parameters.AddWithValue("$description", description);
command.Parameters.AddWithValue("$id", tagId);
command.ExecuteNonQuery();
@@ -394,9 +394,9 @@ public sealed class WorkspaceRepository : IDisposable
private static ColumnModel ReadColumn(SqliteDataReader reader) =>
new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetInt64(3), reader.GetInt64(4));
- /// Reads a tags row from the reader cursor.
+ /// Reads a tags row from the reader cursor; color text converts to components.
private static TagModel ReadTag(SqliteDataReader reader) =>
- new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetString(3));
+ new(reader.GetInt64(0), reader.GetString(1), RgbColor.Parse(reader.GetString(2)), reader.GetString(3));
/// Creates a command bound to this connection.
private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null)