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.uiscale": "UI scale",
"settings.devmode": "Developer mode",
"settings.on": "On",
"settings.off": "Off",
"dev.title": "Dev spawner",
"dev.hint": "Pick an entry · F9 to hide",
"dev.armed": "In hand: {0} · LMB to spawn, RMB to clear",
+2
View File
@@ -120,6 +120,8 @@
"settings.volume": "Громкость",
"settings.uiscale": "Масштаб UI",
"settings.devmode": "Режим разработчика",
"settings.on": "Вкл",
"settings.off": "Откл",
"dev.title": "Дев-спавнер",
"dev.hint": "Выбери пункт · F9 — скрыть",
"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 ButtonPress = new(62, 88, 120);
private static readonly Color ButtonBorder = new(64, 88, 118);
private static readonly Color ChoiceActiveBg = new(48, 84, 120);
private const int ButtonWidth = 300;
@@ -85,6 +86,32 @@ internal static class Ui
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>
public static VerticalStackPanel Column(int spacing = 10) =>
new()
+124 -61
View File
@@ -7,13 +7,17 @@ using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, громкость).
/// Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>; общий
/// для сцены настроек и оверлея паузы. Вместо неоднозначных комбо/чекбоксов — кнопки-сегменты
/// и тумблеры (состояние показывает цвет текста), что переносимо между версиями Myra.
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, масштаб UI, громкость,
/// режим разработчика). Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>;
/// общий для сцены настроек и оверлея паузы. Каждая настройка — ряд «подпись + контрол» с единым
/// выравниванием: сегменты-кнопки (активная подсвечена акцентом) и тумблеры Вкл/Откл вместо
/// неоднозначных комбо/чекбоксов, что переносимо между версиями Myra. Любой клик мгновенно
/// перерисовывает подсветку (все рефрешеры), а не только после «Применить».
/// </summary>
internal static class SettingsPanel
{
private const int LabelWidth = 200;
private static readonly (int W, int H)[] Resolutions =
{
(1280, 720),
@@ -30,47 +34,58 @@ internal static class SettingsPanel
)
{
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")));
column.Widgets.Add(new Label { Height = 8 });
var card = Ui.Card(8);
card.Widgets.Add(Ui.Title(lang.Get("settings.title")));
card.Widgets.Add(Ui.AccentRule());
// Язык — по одному сегменту на доступный язык.
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.language"),
lang.AvailableLanguages,
code => code.ToUpperInvariant(),
() => settings.Language,
code => settings.Language = code,
refreshers
refreshers,
RefreshAll
)
);
// Полный экран и VSync — тумблеры (цвет = состояние).
column.Widgets.Add(
// Полный экран и VSync — тумблеры Вкл/Откл.
card.Widgets.Add(
Toggle(
lang,
lang.Get("settings.fullscreen"),
() => settings.Fullscreen,
v => settings.Fullscreen = v,
refreshers
refreshers,
RefreshAll
)
);
column.Widgets.Add(
card.Widgets.Add(
Toggle(
lang,
lang.Get("settings.vsync"),
() => settings.VSync,
v => settings.VSync = v,
refreshers
refreshers,
RefreshAll
)
);
// Разрешение — сегменты пресетов.
var resolutions = new List<(int, int)>(Resolutions);
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.resolution"),
resolutions,
new List<(int, int)>(Resolutions),
r => $"{r.Item1}×{r.Item2}",
() => (settings.Width, settings.Height),
r =>
@@ -78,48 +93,69 @@ internal static class SettingsPanel
settings.Width = r.Item1;
settings.Height = r.Item2;
},
refreshers
refreshers,
RefreshAll
)
);
// Масштаб интерфейса — сегменты пресетов.
var scales = new List<float> { 0.75f, 1f, 1.25f, 1.5f };
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.uiscale"),
scales,
new List<float> { 0.75f, 1f, 1.25f, 1.5f },
s => $"{(int)MathF.Round(s * 100)}%",
() => settings.UiScale,
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(
lang,
lang.Get("settings.devmode"),
() => settings.DeveloperMode,
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);
buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, 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)
{
refresh();
RefreshAll();
return card;
}
return column;
// Ряд «подпись слева фиксированной ширины + контрол» — даёт ровные колонки между настройками.
private static HorizontalStackPanel Field(string label, Widget control)
{
var row = new HorizontalStackPanel
{
Spacing = 12,
HorizontalAlignment = HorizontalAlignment.Left,
};
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>(
@@ -128,67 +164,94 @@ internal static class SettingsPanel
Func<T, string> text,
Func<T> get,
Action<T> set,
List<Action> refreshers
List<Action> refreshers,
Action onChange
)
{
var row = Ui.Row(6);
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
var group = new HorizontalStackPanel
{
Spacing = 4,
VerticalAlignment = VerticalAlignment.Center,
};
foreach (var option in options)
{
var value = option;
var button = new TextButton { Text = text(value) };
button.Click += (_, _) => set(value);
row.Widgets.Add(button);
var button = Ui.ChoiceButton(text(value));
button.Click += (_, _) =>
{
set(value);
onChange();
};
group.Widgets.Add(button);
refreshers.Add(() =>
button.TextColor = EqualityComparer<T>.Default.Equals(get(), value)
? Ui.Accent
: Ui.Muted
Ui.SetChoiceActive(button, EqualityComparer<T>.Default.Equals(get(), value))
);
}
return row;
return Field(label, group);
}
private static Widget Toggle(
LanguageManager lang,
string label,
Func<bool> get,
Action<bool> set,
List<Action> refreshers
List<Action> refreshers,
Action onChange
)
{
var button = new TextButton();
button.Click += (_, _) => set(!get());
var button = Ui.ChoiceButton("");
button.Width = 90;
button.Click += (_, _) =>
{
set(!get());
onChange();
};
refreshers.Add(() =>
{
button.Text = (get() ? "● " : "○ ") + label;
button.TextColor = get() ? Ui.Accent : Ui.Muted;
button.Text = lang.Get(get() ? "settings.on" : "settings.off");
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 caption = new Label { TextColor = Ui.Muted };
refreshers.Add(() => caption.Text = $"{label}: {(int)MathF.Round(settings.Volume * 100)}%");
var value = new Label
{
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)
{
settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f);
foreach (var refresh in refreshers)
{
refresh();
}
onChange();
}
var minus = new TextButton { Text = "" };
var minus = Ui.ChoiceButton("");
minus.Width = 42;
minus.Click += (_, _) => Adjust(-0.1f);
var plus = new TextButton { Text = "+" };
var plus = Ui.ChoiceButton("+");
plus.Width = 42;
plus.Click += (_, _) => Adjust(+0.1f);
row.Widgets.Add(caption);
row.Widgets.Add(minus);
row.Widgets.Add(plus);
return row;
var group = new HorizontalStackPanel
{
Spacing = 4,
VerticalAlignment = VerticalAlignment.Center,
};
group.Widgets.Add(minus);
group.Widgets.Add(value);
group.Widgets.Add(plus);
return Field(label, group);
}
}