UI: rework settings panel — card, aligned rows, themed segmented controls

The settings screen was ragged: bare gray TextButtons (off-theme), each row
center-aligned on its own so labels/controls never lined up, and a latent bug —
clicking a segment or toggle only changed the value, the highlight didn''t refresh
until Apply rebuilt the panel.

- Wrap in a Card with title + accent rule, matching the other menus.
- Every setting is a "label (fixed width) + control" row, left-aligned, so the
  controls form a clean column.
- Segments and toggles use the themed ChoiceButton with an active state
  (Ui.SetChoiceActive: accent fill/border) instead of text-color-only.
- Toggles read On/Off (new settings.on/off loc) instead of ●/○ glyphs.
- Any click now runs all refreshers immediately, so highlights track the value
  live, not just after Apply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-15 01:22:30 +03:00
co-authored by Claude Opus 4.8
parent 36b6d576a1
commit 92d0302138
4 changed files with 156 additions and 62 deletions
+2
View File
@@ -120,6 +120,8 @@
"settings.volume": "Volume", "settings.volume": "Volume",
"settings.uiscale": "UI scale", "settings.uiscale": "UI scale",
"settings.devmode": "Developer mode", "settings.devmode": "Developer mode",
"settings.on": "On",
"settings.off": "Off",
"dev.title": "Dev spawner", "dev.title": "Dev spawner",
"dev.hint": "Pick an entry · F9 to hide", "dev.hint": "Pick an entry · F9 to hide",
"dev.armed": "In hand: {0} · LMB to spawn, RMB to clear", "dev.armed": "In hand: {0} · LMB to spawn, RMB to clear",
+2
View File
@@ -120,6 +120,8 @@
"settings.volume": "Громкость", "settings.volume": "Громкость",
"settings.uiscale": "Масштаб UI", "settings.uiscale": "Масштаб UI",
"settings.devmode": "Режим разработчика", "settings.devmode": "Режим разработчика",
"settings.on": "Вкл",
"settings.off": "Откл",
"dev.title": "Дев-спавнер", "dev.title": "Дев-спавнер",
"dev.hint": "Выбери пункт · F9 — скрыть", "dev.hint": "Выбери пункт · F9 — скрыть",
"dev.armed": "В руке: {0} · ЛКМ — спавн, ПКМ — снять", "dev.armed": "В руке: {0} · ЛКМ — спавн, ПКМ — снять",
+27
View File
@@ -33,6 +33,7 @@ internal static class Ui
private static readonly Color ButtonOver = new(46, 64, 88); private static readonly Color ButtonOver = new(46, 64, 88);
private static readonly Color ButtonPress = new(62, 88, 120); private static readonly Color ButtonPress = new(62, 88, 120);
private static readonly Color ButtonBorder = new(64, 88, 118); private static readonly Color ButtonBorder = new(64, 88, 118);
private static readonly Color ChoiceActiveBg = new(48, 84, 120);
private const int ButtonWidth = 300; private const int ButtonWidth = 300;
@@ -85,6 +86,32 @@ internal static class Ui
return button; return button;
} }
/// <summary>Небольшая кнопка-выбор (сегмент/тумблер): тёмная заливка, рамка, подсветка hover/press.
/// Активное состояние задаётся отдельно через <see cref="SetChoiceActive"/>.</summary>
public static TextButton ChoiceButton(string text)
{
var button = new TextButton
{
Text = text,
Padding = new Thickness(10, 6),
TextColor = Text,
Background = new SolidBrush(ButtonBg),
OverBackground = new SolidBrush(ButtonOver),
PressedBackground = new SolidBrush(ButtonPress),
Border = new SolidBrush(ButtonBorder),
BorderThickness = new Thickness(1),
};
return button;
}
/// <summary>Подсвечивает кнопку-выбор как выбранную (акцентная заливка/рамка) или обычную.</summary>
public static void SetChoiceActive(TextButton button, bool active)
{
button.Background = new SolidBrush(active ? ChoiceActiveBg : ButtonBg);
button.Border = new SolidBrush(active ? Accent : ButtonBorder);
button.TextColor = active ? Color.White : Muted;
}
/// <summary>Вертикальная колонка по центру экрана.</summary> /// <summary>Вертикальная колонка по центру экрана.</summary>
public static VerticalStackPanel Column(int spacing = 10) => public static VerticalStackPanel Column(int spacing = 10) =>
new() new()
+125 -62
View File
@@ -7,13 +7,17 @@ using Myra.Graphics2D.UI;
namespace LittleSim.UI; namespace LittleSim.UI;
/// <summary> /// <summary>
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, громкость). /// Конструктор панели настроек (язык, полный экран, VSync, разрешение, масштаб UI, громкость,
/// Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>; общий /// режим разработчика). Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>;
/// для сцены настроек и оверлея паузы. Вместо неоднозначных комбо/чекбоксов — кнопки-сегменты /// общий для сцены настроек и оверлея паузы. Каждая настройка — ряд «подпись + контрол» с единым
/// и тумблеры (состояние показывает цвет текста), что переносимо между версиями Myra. /// выравниванием: сегменты-кнопки (активная подсвечена акцентом) и тумблеры Вкл/Откл вместо
/// неоднозначных комбо/чекбоксов, что переносимо между версиями Myra. Любой клик мгновенно
/// перерисовывает подсветку (все рефрешеры), а не только после «Применить».
/// </summary> /// </summary>
internal static class SettingsPanel internal static class SettingsPanel
{ {
private const int LabelWidth = 200;
private static readonly (int W, int H)[] Resolutions = private static readonly (int W, int H)[] Resolutions =
{ {
(1280, 720), (1280, 720),
@@ -30,47 +34,58 @@ internal static class SettingsPanel
) )
{ {
var refreshers = new List<Action>(); var refreshers = new List<Action>();
var column = Ui.Column(10); void RefreshAll()
{
foreach (var refresh in refreshers)
{
refresh();
}
}
column.Widgets.Add(Ui.Title(lang.Get("settings.title"))); var card = Ui.Card(8);
column.Widgets.Add(new Label { Height = 8 }); card.Widgets.Add(Ui.Title(lang.Get("settings.title")));
card.Widgets.Add(Ui.AccentRule());
// Язык — по одному сегменту на доступный язык. // Язык — по одному сегменту на доступный язык.
column.Widgets.Add( card.Widgets.Add(
Segment( Segment(
lang.Get("settings.language"), lang.Get("settings.language"),
lang.AvailableLanguages, lang.AvailableLanguages,
code => code.ToUpperInvariant(), code => code.ToUpperInvariant(),
() => settings.Language, () => settings.Language,
code => settings.Language = code, code => settings.Language = code,
refreshers refreshers,
RefreshAll
) )
); );
// Полный экран и VSync — тумблеры (цвет = состояние). // Полный экран и VSync — тумблеры Вкл/Откл.
column.Widgets.Add( card.Widgets.Add(
Toggle( Toggle(
lang,
lang.Get("settings.fullscreen"), lang.Get("settings.fullscreen"),
() => settings.Fullscreen, () => settings.Fullscreen,
v => settings.Fullscreen = v, v => settings.Fullscreen = v,
refreshers refreshers,
RefreshAll
) )
); );
column.Widgets.Add( card.Widgets.Add(
Toggle( Toggle(
lang,
lang.Get("settings.vsync"), lang.Get("settings.vsync"),
() => settings.VSync, () => settings.VSync,
v => settings.VSync = v, v => settings.VSync = v,
refreshers refreshers,
RefreshAll
) )
); );
// Разрешение — сегменты пресетов. // Разрешение — сегменты пресетов.
var resolutions = new List<(int, int)>(Resolutions); card.Widgets.Add(
column.Widgets.Add(
Segment( Segment(
lang.Get("settings.resolution"), lang.Get("settings.resolution"),
resolutions, new List<(int, int)>(Resolutions),
r => $"{r.Item1}×{r.Item2}", r => $"{r.Item1}×{r.Item2}",
() => (settings.Width, settings.Height), () => (settings.Width, settings.Height),
r => r =>
@@ -78,48 +93,69 @@ internal static class SettingsPanel
settings.Width = r.Item1; settings.Width = r.Item1;
settings.Height = r.Item2; settings.Height = r.Item2;
}, },
refreshers refreshers,
RefreshAll
) )
); );
// Масштаб интерфейса — сегменты пресетов. // Масштаб интерфейса — сегменты пресетов.
var scales = new List<float> { 0.75f, 1f, 1.25f, 1.5f }; card.Widgets.Add(
column.Widgets.Add(
Segment( Segment(
lang.Get("settings.uiscale"), lang.Get("settings.uiscale"),
scales, new List<float> { 0.75f, 1f, 1.25f, 1.5f },
s => $"{(int)MathF.Round(s * 100)}%", s => $"{(int)MathF.Round(s * 100)}%",
() => settings.UiScale, () => settings.UiScale,
s => settings.UiScale = s, s => settings.UiScale = s,
refreshers refreshers,
RefreshAll
) )
); );
// Громкость — кнопки −/+ с подписью процента. // Громкость — кнопки −/+ с подписью процента.
column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers)); card.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers, RefreshAll));
// Режим разработчика — тумблер (дев-консоль и спавнер; по умолчанию включён). // Режим разработчика — тумблер (дев-консоль и спавнер; по умолчанию включён).
column.Widgets.Add( card.Widgets.Add(
Toggle( Toggle(
lang,
lang.Get("settings.devmode"), lang.Get("settings.devmode"),
() => settings.DeveloperMode, () => settings.DeveloperMode,
v => settings.DeveloperMode = v, v => settings.DeveloperMode = v,
refreshers refreshers,
RefreshAll
) )
); );
column.Widgets.Add(new Label { Height = 8 }); card.Widgets.Add(new Label { Height = 8 });
var buttons = Ui.Row(10); var buttons = Ui.Row(10);
buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, width: 150)); buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, width: 150));
buttons.Widgets.Add(Ui.Button(lang.Get("settings.back"), onBack, width: 150)); buttons.Widgets.Add(Ui.Button(lang.Get("settings.back"), onBack, width: 150));
column.Widgets.Add(buttons); card.Widgets.Add(buttons);
foreach (var refresh in refreshers) RefreshAll();
return card;
}
// Ряд «подпись слева фиксированной ширины + контрол» — даёт ровные колонки между настройками.
private static HorizontalStackPanel Field(string label, Widget control)
{
var row = new HorizontalStackPanel
{ {
refresh(); Spacing = 12,
} HorizontalAlignment = HorizontalAlignment.Left,
};
return column; row.Widgets.Add(
new Label
{
Text = label,
Width = LabelWidth,
Wrap = true,
TextColor = Ui.Muted,
VerticalAlignment = VerticalAlignment.Center,
}
);
row.Widgets.Add(control);
return row;
} }
private static Widget Segment<T>( private static Widget Segment<T>(
@@ -128,67 +164,94 @@ internal static class SettingsPanel
Func<T, string> text, Func<T, string> text,
Func<T> get, Func<T> get,
Action<T> set, Action<T> set,
List<Action> refreshers List<Action> refreshers,
Action onChange
) )
{ {
var row = Ui.Row(6); var group = new HorizontalStackPanel
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted }); {
Spacing = 4,
VerticalAlignment = VerticalAlignment.Center,
};
foreach (var option in options) foreach (var option in options)
{ {
var value = option; var value = option;
var button = new TextButton { Text = text(value) }; var button = Ui.ChoiceButton(text(value));
button.Click += (_, _) => set(value); button.Click += (_, _) =>
row.Widgets.Add(button); {
set(value);
onChange();
};
group.Widgets.Add(button);
refreshers.Add(() => refreshers.Add(() =>
button.TextColor = EqualityComparer<T>.Default.Equals(get(), value) Ui.SetChoiceActive(button, EqualityComparer<T>.Default.Equals(get(), value))
? Ui.Accent
: Ui.Muted
); );
} }
return row; return Field(label, group);
} }
private static Widget Toggle( private static Widget Toggle(
LanguageManager lang,
string label, string label,
Func<bool> get, Func<bool> get,
Action<bool> set, Action<bool> set,
List<Action> refreshers List<Action> refreshers,
Action onChange
) )
{ {
var button = new TextButton(); var button = Ui.ChoiceButton("");
button.Click += (_, _) => set(!get()); button.Width = 90;
button.Click += (_, _) =>
{
set(!get());
onChange();
};
refreshers.Add(() => refreshers.Add(() =>
{ {
button.Text = (get() ? "● " : "○ ") + label; button.Text = lang.Get(get() ? "settings.on" : "settings.off");
button.TextColor = get() ? Ui.Accent : Ui.Muted; Ui.SetChoiceActive(button, get());
}); });
return button; return Field(label, button);
} }
private static Widget Volume(string label, GameSettings settings, List<Action> refreshers) private static Widget Volume(
string label,
GameSettings settings,
List<Action> refreshers,
Action onChange
)
{ {
var row = Ui.Row(6); var value = new Label
var caption = new Label { TextColor = Ui.Muted }; {
refreshers.Add(() => caption.Text = $"{label}: {(int)MathF.Round(settings.Volume * 100)}%"); TextColor = Ui.Text,
Width = 56,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
refreshers.Add(() => value.Text = $"{(int)MathF.Round(settings.Volume * 100)}%");
void Adjust(float delta) void Adjust(float delta)
{ {
settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f); settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f);
foreach (var refresh in refreshers) onChange();
{
refresh();
}
} }
var minus = new TextButton { Text = "" }; var minus = Ui.ChoiceButton("");
minus.Width = 42;
minus.Click += (_, _) => Adjust(-0.1f); minus.Click += (_, _) => Adjust(-0.1f);
var plus = new TextButton { Text = "+" }; var plus = Ui.ChoiceButton("+");
plus.Width = 42;
plus.Click += (_, _) => Adjust(+0.1f); plus.Click += (_, _) => Adjust(+0.1f);
row.Widgets.Add(caption); var group = new HorizontalStackPanel
row.Widgets.Add(minus); {
row.Widgets.Add(plus); Spacing = 4,
return row; VerticalAlignment = VerticalAlignment.Center,
};
group.Widgets.Add(minus);
group.Widgets.Add(value);
group.Widgets.Add(plus);
return Field(label, group);
} }
} }