From 80ec7450c81277e2624c59f93844dde3f184dbcd Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 12:07:39 +0200 Subject: [PATCH] feat(chat): stop repeating the same minute on every line The comparison value comes from the message data, not from a variable carried between rows. In 1.5.6 the loop walked every message and skipped invisible ones with a dummy, so what it remembered was the last *visible* stamp. The virtualised list only iterates the visible window, so the row above that window was never drawn at all -- a carried variable would hold whatever was on screen before the last scroll, and the first stamp after every jump would be wrong. No predecessor means draw. Scrolling into the middle of a log would otherwise swallow the only stamp on screen. Both draw paths now pass an index; the linear one was a foreach and had none. The setting is on by default and existed with translations in twenty-three languages -- Catalan and Italian had kept the English string, so those two are done now. It needs no fingerprint entry: the column stays reserved when the stamp is suppressed, so hiding one changes no row's height. --- HellionChat/Resources/Language.ca.resx | 4 +- HellionChat/Resources/Language.it.resx | 4 +- HellionChat/Ui/Components/MessageList.cs | 38 +++++++++++-------- .../Ui/Components/RepeatedTimestamp.cs | 20 ++++++++++ .../Ui/Components/Settings/Tabs/ChatTab.cs | 7 ++++ 5 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 HellionChat/Ui/Components/RepeatedTimestamp.cs diff --git a/HellionChat/Resources/Language.ca.resx b/HellionChat/Resources/Language.ca.resx index 2eee568..7d47e2f 100644 --- a/HellionChat/Resources/Language.ca.resx +++ b/HellionChat/Resources/Language.ca.resx @@ -527,10 +527,10 @@ Finestra emergent - Hide timestamps when redundant + Amaga les marques de temps redundants - Hide timestamps when previous messages have the same timestamp. + Amaga la marca de temps quan el missatge anterior ja en té la mateixa. Show title bar for popped-out tabs diff --git a/HellionChat/Resources/Language.it.resx b/HellionChat/Resources/Language.it.resx index cfff142..5bae064 100644 --- a/HellionChat/Resources/Language.it.resx +++ b/HellionChat/Resources/Language.it.resx @@ -527,10 +527,10 @@ Pop out - Hide timestamps when redundant + Nascondi gli orari ridondanti - Hide timestamps when previous messages have the same timestamp. + Nasconde l'orario quando il messaggio precedente ha già lo stesso. Show title bar for popped-out tabs diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 8756ab6..f11d73e 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -34,8 +34,8 @@ internal sealed class MessageList // Bound once. A method group off an instance method captures `this` and is // not cached by Roslyn, so `compact ? DrawCompactRow : DrawCardRow` would // allocate a delegate on every frame of every window. - private readonly Action _drawCompactRow; - private readonly Action _drawCardRow; + private readonly Action _drawCompactRow; + private readonly Action _drawCardRow; // Reused across frames: at MessageManager.MessageDisplayLimit a fresh array // per frame is 40 KB of garbage, and A2 put the default density on this @@ -261,15 +261,20 @@ internal sealed class MessageList // Draws the stamp into its column and leaves the cursor at the text column, // whether or not anything was drawn. - private void DrawTimestampCell(Message message) + private void DrawTimestampCell(Message message, string? previousStamp) { var origin = ImGui.GetCursorPos(); - if (_stampVisible) + var stamp = FormatTimestamp(message.Date); + var draw = + _stampVisible + && RepeatedTimestamp.ShouldDraw(Plugin.Config.HideSameTimestamps, stamp, previousStamp); + + if (draw) { ImGui.SetCursorPosY(origin.Y + _metaDrop); using (MetaFace().Push()) - ImGui.TextUnformatted(FormatTimestamp(message.Date)); + ImGui.TextUnformatted(stamp); ImGui.SameLine(0f, 0f); } @@ -328,7 +333,7 @@ internal sealed class MessageList _scrollToBottomRequested = true; } - private void DrawCompactRow(Message message) + private void DrawCompactRow(Message message, string? previousStamp) { // B2-1/B2-2: render the sender through DrawChunks (the name-aware path // that applies WorldSuffixMode/NameFormMode via ForDisplay), not as a @@ -336,7 +341,7 @@ internal sealed class MessageList // channel brackets/colon as ChunkSource.None wrappers (MessageManager // .cs:300-314), so the separator is rendered by the chunks. 1.5.6 parity // (ChatLogWindow.cs:1965: DrawChunks(message.Sender) + SameLine). - DrawTimestampCell(message); + DrawTimestampCell(message, previousStamp); if (message.Sender.Count == 0) { @@ -365,7 +370,7 @@ internal sealed class MessageList private void DrawRows( Tab tab, IReadOnlyList messages, - Action drawRow, + Action drawRow, bool frozen ) { @@ -438,7 +443,9 @@ internal sealed class MessageList // the text instead of needing a draw-channel detour. DrawRowSurface(msg, heights[i]); - drawRow(msg); + // From the data, not from a variable carried between rows: this loop + // starts at FirstVisible, so the row above the window was never drawn. + drawRow(msg, i > 0 ? FormatTimestamp(messages[i - 1].Date) : null); if (frozen) continue; @@ -531,7 +538,7 @@ internal sealed class MessageList private void DrawLinearAndMeasure( Guid tabId, IReadOnlyList messages, - Action drawRow + Action drawRow ) { // No row has a cached height on this frame, so the surface cannot be @@ -540,12 +547,13 @@ internal sealed class MessageList // frame after every resize. using var surfaces = StyleEngine.RowSurfaceScope.Push(); - foreach (var msg in messages) + for (var i = 0; i < messages.Count; i++) { + var msg = messages[i]; var before = ImGui.GetCursorPosY(); var top = ImGui.GetCursorScreenPos(); - drawRow(msg); + drawRow(msg, i > 0 ? FormatTimestamp(messages[i - 1].Date) : null); var after = ImGui.GetCursorPosY(); var height = after - before; @@ -556,7 +564,7 @@ internal sealed class MessageList } } - private void DrawCardRow(Message message) + private void DrawCardRow(Message message, string? previousStamp) { // B2-1/B2-2: sender via DrawChunks (name-aware path), on its own line // with content below — 1.5.6 card parity (ChatLogWindow.cs:1913, no @@ -568,7 +576,7 @@ internal sealed class MessageList // densities; only a message with a sender gets the two-line treatment. if (message.Sender.Count == 0) { - DrawTimestampCell(message); + DrawTimestampCell(message, previousStamp); using (ItalicFace().Push()) _chunkRenderer.DrawChunks( message.Content, @@ -580,7 +588,7 @@ internal sealed class MessageList return; } - DrawTimestampCell(message); + DrawTimestampCell(message, previousStamp); using (SenderFace().Push()) _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); diff --git a/HellionChat/Ui/Components/RepeatedTimestamp.cs b/HellionChat/Ui/Components/RepeatedTimestamp.cs new file mode 100644 index 0000000..a9f1a19 --- /dev/null +++ b/HellionChat/Ui/Components/RepeatedTimestamp.cs @@ -0,0 +1,20 @@ +namespace HellionChat.Ui.Components; + +// TEST-MIRROR: Ui/RepeatedTimestampTests.cs +// +// Whether a row draws its timestamp, given the one above it. +// +// The comparison value has to come from the message data, not from a drawing +// state carried between rows. In 1.5.6 the loop walked every message and skipped +// the invisible ones with a dummy, so the "last stamp" it remembered was the last +// visible one. The virtualised list only iterates the visible window, so a row at +// the top of that window has a predecessor in the data that was never drawn -- +// and a state variable would hold whatever was on screen before the scroll. +// +// No predecessor means draw. Scrolling into the middle of a log would otherwise +// swallow the only stamp on screen. +internal static class RepeatedTimestamp +{ + internal static bool ShouldDraw(bool enabled, string current, string? previous) => + !enabled || previous is null || !string.Equals(current, previous, StringComparison.Ordinal); +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 46a47fc..88fa1b2 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -39,6 +39,13 @@ internal sealed class ChatTab () => Plugin.Config.Use24HourClock, v => Plugin.Config.Use24HourClock = v ); + _w.ToggleRow( + ImGui.GetID("chat.display.samestamps"u8), + Language.Options_HideSameTimestamps_Name, + Language.Options_HideSameTimestamps_Description, + () => Plugin.Config.HideSameTimestamps, + v => Plugin.Config.HideSameTimestamps = v + ); // Descriptions move out of the help markers and onto the row. They // were written to be read; a (?) the user has to hover is where an