feat(chat): put the channel header above both windows

Drawn before the scroll child in each, so it stays put while the log moves. The
child's height is deliberately left alone: it is given as a negative value, and
ImGui resolves those against the space still available from the current cursor --
which the header has already reduced. Subtracting it a second time would have
opened a gap of exactly the header's height above the input row, in both windows.

Where the name shows follows one rule: never twice on the same screen. The
sidebar layout is the only place nothing else carries it, so that is the only
place the header says it. The top-tab strip carries it, and a pop-out with its
title bar on carries it in the title.

The pop-out's plain title row is gone, replaced by the header. The comment it
left behind is worth keeping in mind -- an earlier header row was removed exactly
because it repeated the tab name one line below the title bar. That warning is
now the rule rather than a reason to have no header at all.

The trailing detail is the home world and the clock, and the clock follows the
same Use24HourClock setting the message timestamps do. Two clock formats in one
window, with the header sitting directly above a column of timestamps, would be a
defect rather than a preference. Culture is pinned for the same reason it is
pinned in the message list.
This commit is contained in:
2026-08-19 09:58:29 +02:00
parent 74f9ff7f16
commit 100290ea4d
30 changed files with 165 additions and 13 deletions
+1
View File
@@ -388,6 +388,7 @@ internal class HellionStrings
internal static string StatusBar_MessagesThousands => Get(nameof(StatusBar_MessagesThousands));
internal static string Settings_Preview_TitleMock => Get(nameof(Settings_Preview_TitleMock));
internal static string Settings_Preview_StatusOpen => Get(nameof(Settings_Preview_StatusOpen));
internal static string ChannelHeader_NotLoggedIn => Get(nameof(ChannelHeader_NotLoggedIn));
internal static string Settings_Section_Links => Get(nameof(Settings_Section_Links));
internal static string Settings_Section_Behaviour => Get(nameof(Settings_Section_Behaviour));
internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds));
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>obert</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sense sessió</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>otevřeno</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nepřihlášen</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>åben</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ikke logget ind</value>
</data>
</root>
@@ -1352,4 +1352,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>offen</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nicht eingeloggt</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>ανοιχτό</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Εκτός σύνδεσης</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>abierto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sin sesión</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>auki</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ei kirjautunut sisään</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>ouvert</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Non connecté</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>nyitva</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nincs bejelentkezve</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>aperto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Non connesso</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>開いています</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未ログイン</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>열림</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>로그인하지 않음</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>åpen</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ikke innlogget</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>open</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Niet ingelogd</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>otwarte</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Niezalogowany</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>aberto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Não conectado</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>aberto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sem sessão iniciada</value>
</data>
</root>
@@ -1369,4 +1369,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>open</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Not logged in</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>deschis</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Neconectat</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>открыт</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Не выполнен вход</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>öppen</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Inte inloggad</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>açık</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Oturum açılmadı</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>відкрито</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Вхід не виконано</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>打开</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未登录</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>開啟</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未登入</value>
</data>
</root>
@@ -131,6 +131,24 @@ internal static class ChannelHeader
ImGuiP.ItemSize(new Vector2(width, height - ImGui.GetStyle().ItemSpacing.Y));
}
// Both windows need the same string, and only one of them should know how to
// get at the world. IsValid is the guard the payload handler already uses --
// HomeWorld is a row reference that stays unresolved until a character is
// actually logged in.
internal static string CurrentDetail()
{
var world = Plugin.PlayerState.HomeWorld.IsValid
? Plugin.PlayerState.HomeWorld.Value.Name.ExtractText()
: null;
return ChannelHeaderDetail.Format(
world,
DateTimeOffset.Now,
Plugin.Config.Use24HourClock,
Resources.HellionStrings.ChannelHeader_NotLoggedIn
);
}
private static float MeasureIcon(FontManager fonts, FontAwesomeIcon icon)
{
using (fonts.FontAwesome.Push())
@@ -0,0 +1,34 @@
using System.Globalization;
namespace HellionChat.Ui.StyleEngine.Widgets;
// TEST-MIRROR: Ui/ChannelHeaderDetailTests.cs
//
// The trailing half of the channel header: where you are and what time it is.
//
// The clock follows the same Use24HourClock setting the message timestamps do.
// Two clock formats in one window would be a defect rather than a preference, and
// the header sits directly above a column of timestamps.
//
// Culture is pinned for the same reason it is pinned in the message list: a
// German machine renders "PM" as "nachm." under its own culture, and the two
// would then disagree on the same screen.
internal static class ChannelHeaderDetail
{
internal const string Separator = " · ";
internal static string Format(
string? world,
DateTimeOffset now,
bool use24Hour,
string fallback
)
{
var where = string.IsNullOrWhiteSpace(world) ? fallback : world;
var clock = use24Hour
? now.ToString("HH:mm", CultureInfo.InvariantCulture)
: now.ToString("h:mm tt", CultureInfo.InvariantCulture);
return string.Concat(where, Separator, clock);
}
}
+17 -13
View File
@@ -123,14 +123,23 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow
if (Bound is null)
return;
// No header row at all any more. With the title bar on it repeated the
// tab name one line below itself; with the bar off, hiding it took the
// only way out of the window with it, because the title bar carries no
// close button either -- closing has to go through the pool so the slot
// is released. Pop-in lives in the input row now, where the other window
// actions already are.
if (!Plugin.Config.ShowPopOutTitleBar)
DrawTitle(Bound);
// v1.13.0: the plain title row became the channel header. The warning it
// left behind still holds and is now the rule the header follows -- with
// the title bar on, the window title already carries the tab name, so the
// header drops the name and shows only the world and the clock.
//
// Drawn before the body child on purpose, and the child's height is left
// alone: it is a negative value, which ImGui resolves against the space
// still available from the current cursor, and the header has already
// taken its share.
StyleEngine.Widgets.ChannelHeader.Draw(
Bound,
Plugin.Config.ShowPopOutTitleBar
? StyleEngine.Widgets.ChannelHeaderMode.DetailOnly
: StyleEngine.Widgets.ChannelHeaderMode.Full,
Plugin.Instance.FontManager,
StyleEngine.Widgets.ChannelHeader.CurrentDetail()
);
// The header close button can unbind us mid-frame (CloseRequested ->
// pool.TryClose -> Unbind nulls Bound). Re-check before the body so we
@@ -165,9 +174,4 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow
}
// Name only. Shown when the window has no title bar to carry it.
private void DrawTitle(Tab tab)
{
ImGui.TextUnformatted(tab.Name);
ImGui.Separator();
}
}
+20
View File
@@ -388,6 +388,26 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
? Plugin.InputPreview.PreviewHeight
: 0f;
// Before the child, so it does not scroll away with the log. The child's
// height is left alone on purpose: it is given as a negative value, and
// ImGui resolves those against the space still available from the current
// cursor -- which the header has already reduced. Subtracting it a second
// time would open a gap of exactly the header's height above the input row.
if (_activeTab is { } headerTab)
{
var mode =
Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs
? StyleEngine.Widgets.ChannelHeaderMode.DetailOnly
: StyleEngine.Widgets.ChannelHeaderMode.Full;
StyleEngine.Widgets.ChannelHeader.Draw(
headerTab,
mode,
Plugin.Instance.FontManager,
StyleEngine.Widgets.ChannelHeader.CurrentDetail()
);
}
using (
var messages = ImRaii.Child(
"##hellion-main-area",