Merge pull request #11534 from martipops/settings/shell-api

🤖 Agent: Extras moves onto the new settings_shell (one row per setting, search, description strip), same items and saves; design-system.md now lists Extras with Graphics/Controller as Blocks-frame pages; size (+1748) accepted because ~860 lines are the shell the next pages reuse (#11487 plan). (union of 74 heads on main: fmt, clippy -D warnings, tests green).
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 13:24:55 +01:00
commit 951fc0f0cb
10 files changed
+1753 -212

No files matched your search

@@ -604,6 +604,7 @@ impl ControllerUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
}
@@ -0,0 +1,231 @@
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
use crate::{
extras::{self, Item},
lang,
options_ui::{Event, Page},
settings::Settings,
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
};
const TITLE: &str = "horizon.pages.extras";
const ABOUT: &str = "horizon.pages.extras.tooltip";
/// The group of the mod that replaced an extra.
const MODS: &str = "horizon.pages.mods";
const ZOOM: &str = "horizon.extras.zoom";
const ZOOM_INSTALL: &str = "horizon.extras.zoom_mod_install";
const MOD_SETTINGS: &str = "horizon.mods.settings";
/// Its descriptions run long, as tooltips they were: most need more than two lines.
const NOTE_LINES: usize = 5;
/// The official native mod that took over from the Zoom extra (#8713).
const ZOOM_ID: &str = "zoom";
/// A row of the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ExtraRow {
Item(Item),
/// The Zoom mod, at this place among the installed ones, on or off.
Zoom(usize),
/// Its own settings page.
ZoomSettings(usize),
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
ZoomInstall,
}
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
let e = &s.extras;
let value = match (item.key_name(), item.switch(e)) {
(Some(key), _) => Value::Key(key, item.value(e)),
(None, Some(on)) => Value::Switch(on),
(None, None) => Value::Choice(item.value(e)),
};
// A setting of a switch that is off does nothing, so it is greyed out.
let live = item
.parent()
.is_none_or(|parent| parent.switch(e) != Some(false));
Row::new(ExtraRow::Item(item), item.caption_text(), value)
.about(item.description_text())
.live(live)
}
/// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now; Extras keeps a way to
/// the one players look for here.
fn zoom_rows(s: &Settings) -> Vec<Row<ExtraRow>> {
let installed = &s.mods.installed;
let found = installed
.iter()
.position(|m| m.id == ZOOM_ID && m.native.is_some());
let Some(i) = found else {
let note = Row::new(ExtraRow::ZoomInstall, lang::get(ZOOM_INSTALL), Value::Note);
return vec![note.live(false)];
};
let about = installed[i].problem.as_ref();
let about = about.unwrap_or(&installed[i].description).clone();
let on = Value::Switch(s.mods.is_on(i));
let mut rows = vec![Row::new(ExtraRow::Zoom(i), lang::get(ZOOM), on).about(about)];
let page = installed[i].native.as_ref();
if page.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty()) {
let name = lang::get(MOD_SETTINGS);
rows.push(Row::new(ExtraRow::ZoomSettings(i), name, Value::Press));
}
rows
}
/// The page itself holds nothing: everything it shows is in the settings.
#[derive(Default)]
pub(crate) struct ExtrasScreen;
impl Screen for ExtrasScreen {
type Page = ();
type Row = ExtraRow;
fn title(&self) -> &'static str {
TITLE
}
fn note_lines(&self) -> usize {
NOTE_LINES
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
name: MODS,
rows: zoom_rows(s),
}];
for row in extras::rows() {
match row {
Err(name) => groups.push(Group {
name,
rows: Vec::new(),
}),
Ok(item) => {
if let Some(group) = groups.last_mut() {
group.rows.push(item_row(item, s));
}
}
}
}
vec![Sheet {
id: (),
name: lang::get(TITLE),
about: lang::get(ABOUT),
groups,
}]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
match row {
ExtraRow::Item(item) => match item.key_name() {
Some(key) => vec![Output::BindKey(key)],
None => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
},
ExtraRow::Zoom(i) => {
s.mods.toggle(i);
vec![Output::Event(Event::Changed)]
}
ExtraRow::ZoomSettings(i) => vec![
Output::Event(Event::ButtonClick),
Output::Push(Page::NativeMod(i)),
Output::Event(Event::NativeSettings(i)),
],
ExtraRow::ZoomInstall => Vec::new(),
}
}
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
/// let the list scroll.
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
let ExtraRow::Item(item) = row else {
return None;
};
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings_shell::{Hosted, Shell};
const SIZE: (f32, f32) = (854.0, 480.0);
fn rows_of(s: &Settings) -> Vec<Row<ExtraRow>> {
let sheets = ExtrasScreen.sheets(s);
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
groups.flat_map(|group| group.rows).collect()
}
/// Greyed exactly while its own switch is off, so it takes no press then.
#[test]
fn a_setting_of_a_switch_that_is_off_is_greyed_and_takes_no_press() {
let mut s = Settings::default();
let live = |s: &Settings| {
let wheel = ExtraRow::Item(Item::MouseTweaksWheel);
rows_of(s).iter().find(|r| r.id == wheel).unwrap().live
};
s.extras.mouse_tweaks = false;
assert!(!live(&s));
let mut shell = Shell::<ExtrasScreen>::default();
let row = ExtraRow::Item(Item::MouseTweaksWheel);
let at = shell.point_at(&s, SIZE, row);
let before = s.extras.mouse_tweaks_wheel;
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_eq!(s.extras.mouse_tweaks_wheel, before);
s.extras.mouse_tweaks = true;
assert!(live(&s));
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::SortKey);
let key = s.extras.sort_key.clone();
assert_eq!(
screen.press(&mut s, row, false),
vec![Output::BindKey(extras::SORT_KEY)]
);
screen.wheel_step(&mut s, row, true);
assert_eq!(s.extras.sort_key, key);
}
#[test]
fn the_wheel_flips_a_switch_and_leaves_the_mod_to_scroll() {
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::Sort);
assert_eq!(
screen.wheel_step(&mut s, row, true),
Some(vec![Output::Event(Event::Changed)])
);
assert!(s.extras.sort);
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
}
}
@@ -1663,6 +1663,7 @@ impl GraphicsUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, cols, y, &view);
}
+1
View File
@@ -42,6 +42,7 @@ pub mod dynamic_light;
pub mod effects;
pub mod environment;
pub mod extras;
pub(crate) mod extras_ui;
pub mod fast_hash;
pub mod fluid_render;
pub mod fog;
+181 -198
View File
@@ -12,6 +12,7 @@ use crate::{
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
lang::{self, LanguageInfo},
settings::{SOUND_SOURCES, Settings},
settings_shell as shell,
text::{self, Style, Text},
ui,
};
@@ -1619,6 +1620,8 @@ pub struct OptionsUi {
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
pub controller: crate::controller_ui::ControllerUi,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
pub shader: crate::shader_ui::ShaderUi,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
@@ -1651,7 +1654,7 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// The Extras or PvP & HUD items that take a whole row (`wide_extras`), measured
/// The PvP & HUD items that take a whole row (`wide_extras`), measured
/// when the page is drawn.
wide_extras: Vec<crate::extras::Item>,
}
@@ -1697,6 +1700,7 @@ impl Default for OptionsUi {
center_language: false,
graphics: Default::default(),
controller: Default::default(),
extras: Default::default(),
shader: Default::default(),
split_profiles: Default::default(),
calibration: None,
@@ -1765,24 +1769,23 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
Kind::Cycle => button(x, y, w, opt.label(s), Act::Cycle(opt)),
}
}
/// The Extras or PvP & HUD page's rows: its items under their headers.
fn extras_rows(page: Page) -> Vec<Result<crate::extras::Item, &'static str>> {
match page {
Page::Pvp => crate::pvp::rows()
.into_iter()
.map(|r| r.map(crate::extras::Item::Pvp))
.collect(),
_ => crate::extras::rows(),
}
/// The least GUI size a shell screen lays its keys out for, before it has been drawn at its own.
const MIN_KEY_GUI: (f32, f32) = (480.0, 240.0);
/// The PvP & HUD page's rows: its items under their headers.
fn pvp_rows() -> Vec<Result<crate::extras::Item, &'static str>> {
crate::pvp::rows()
.into_iter()
.map(|r| r.map(crate::extras::Item::Pvp))
.collect()
}
/// The items of `page`'s rows whose label, with any of their first values, is wider than a
/// The items of PvP & HUD's rows whose label, with any of their first values, is wider than a
/// half-width button holds (`width` measures text with the screen's font): those take the
/// whole row, so no label slides past its edges, in any language.
fn wide_extras(s: &Settings, page: Page, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
// A button's label keeps 2 px off each edge (`AbstractButton.renderString`'s margin),
// past which it slides back and forth.
let room = 150.0 - 4.0;
extras_rows(page)
pvp_rows()
.into_iter()
.filter_map(Result::ok)
.filter(|&item| {
@@ -2006,6 +2009,9 @@ impl OptionsUi {
if !self.focus_from_keyboard {
self.focus = None;
}
if let Some(shell) = self.shell_mut() {
shell.blur_search();
}
}
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
@@ -2058,7 +2064,10 @@ impl OptionsUi {
}
Page::Controller | Page::PadBinds => self.controller.typing(),
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
_ => self.settings_search_active(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
},
}
}
/// Jump straight to a page (control socket / tests).
@@ -2174,6 +2183,9 @@ impl OptionsUi {
if let Some(at) = Self::shader_page(page) {
self.shader.open(at);
}
if let Some(shell) = self.shell_mut() {
shell.open();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
@@ -2184,6 +2196,34 @@ impl OptionsUi {
self.studio.refresh();
}
}
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&mut self.extras),
_ => None,
}
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::BindKey(name) => self.selected_key = Some(name),
shell::Output::Push(page) => self.push(page),
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// What the graphics screen's actions do to these options.
fn graphics_events(
&mut self,
@@ -2534,41 +2574,6 @@ impl OptionsUi {
}
rows
}
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
/// became an official native mod, so Extras has no setting of its own for it (#8713).
fn zoom_mod_row(s: &Settings) -> Row {
let Some(i) = s
.mods
.installed
.iter()
.position(|m| m.id == "zoom" && m.native.is_some())
else {
// A note, not a button: Options > Mods already has the one way into the Mods page
// (`every_setting_and_page_is_reached_from_one_place`).
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
};
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
let settings = s.mods.installed[i]
.native
.as_ref()
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
if settings {
Row::Buttons(
toggle(150.0),
Some(button(
0.0,
0.0,
150.0,
tr("horizon.mods.settings"),
Act::ModSettings(i),
)),
)
} else {
Row::Buttons(toggle(310.0), None)
}
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
@@ -2609,7 +2614,7 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => Vec::new(),
Page::Extras | Page::Pvp => {
Page::Pvp => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen; one whose label would run
// past half a row in this language (`wide_extras`) gets the whole row.
@@ -2637,21 +2642,12 @@ impl OptionsUi {
widget
}
};
// PvP & HUD opens with its preset; Extras doesn't repeat it, nor the way there
// (Horizon Settings has it).
// PvP & HUD opens with its preset (Horizon Settings has the way there).
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
let mut rows = Vec::new();
if self.page() == Page::Extras {
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
// Extras keeps a way to the one players look for here (#8713).
rows.push(Self::zoom_mod_row(s));
}
if self.page() == Page::Pvp {
// The editor moves what the game shows: only from a world.
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
let edit = if self.in_world { edit } else { inactive(edit) };
rows.push(Row::Buttons(preset, Some(edit)));
}
// The editor moves what the game shows: only from a world.
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
let edit = if self.in_world { edit } else { inactive(edit) };
let mut rows = vec![Row::Buttons(preset, Some(edit))];
let mut section = Vec::new();
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
let mut half = None;
@@ -2667,7 +2663,7 @@ impl OptionsUi {
}
rows.extend(half.map(|h| Row::Buttons(extra(h, 150.0), None)));
};
for row in extras_rows(self.page()) {
for row in pvp_rows() {
match row {
Err(header) => {
flush(&mut rows, &mut section);
@@ -3649,6 +3645,12 @@ impl OptionsUi {
self.shader.draw(s, batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.selected_key;
let in_world = self.in_world;
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
// The controller picks a tile up between presses: the screen's size from the draw.
@@ -3677,8 +3679,8 @@ impl OptionsUi {
crate::screen_draw::paint(batch, atlas, &draws);
return;
}
if matches!(self.page(), Page::Extras | Page::Pvp) {
self.wide_extras = wide_extras(s, self.page(), |t| atlas.font.width(t));
if self.page() == Page::Pvp {
self.wide_extras = wide_extras(s, |t| atlas.font.width(t));
}
let (widgets, top, bottom, content) = self.layout(s, w, h);
self.size = (w, h);
@@ -4015,6 +4017,22 @@ impl OptionsUi {
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.graphics_events(s, actions);
}
if self.shell().is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
winit::event::MouseButton::Right
} else {
winit::event::MouseButton::Left
};
return self.bind_mouse(s, button);
}
let shift = self.shift;
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
shell.click(s, (x, y), size, drag, right, shift)
});
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
// Waiting on a button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
@@ -4291,7 +4309,9 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let (shader_slider, shader_pack) = self.shader.release();
let graphics_slider = self.graphics.release() | self.controller.release() | shader_slider;
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
let graphics_slider =
self.graphics.release() | self.controller.release() | shader_slider | shell_slider;
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
@@ -4369,6 +4389,13 @@ impl OptionsUi {
) -> Vec<Event> {
self.size = size;
self.framebuffer = framebuffer;
if self.resource_error.is_some() {
return Vec::new();
}
if let Some(shell) = self.shell_mut() {
let outputs = shell.wheel(s, size, cursor, amount);
return self.shell_events(s, outputs);
}
if Self::graphics_page(self.page()).is_some() {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
@@ -4460,6 +4487,10 @@ impl OptionsUi {
if self.resource_error.is_some() {
return;
}
if let Some(shell) = self.shell_mut() {
shell.scroll(s, size, amount);
return;
}
if Self::graphics_page(self.page()).is_some() {
self.graphics.scroll(s, size, amount);
return;
@@ -4656,6 +4687,10 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if let Some(shell) = self.shell_mut() {
if shell.typing() {
shell.type_text(text);
}
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
@@ -4693,6 +4728,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
@@ -4826,6 +4864,14 @@ impl OptionsUi {
}
return events;
}
let gui = (
self.size.0.max(MIN_KEY_GUI.0),
self.size.1.max(MIN_KEY_GUI.1),
);
if let Some(shell) = self.shell_mut() {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
@@ -5184,6 +5230,7 @@ impl OptionsUi {
#[cfg(test)]
mod tests {
use super::*;
use crate::extras_ui::ExtraRow;
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
@@ -5629,6 +5676,9 @@ mod tests {
if ui.controller_open() {
texts.extend(ui.controller.row_names(&s));
}
if ui.page() == Page::Extras {
texts.extend(ui.extras.row_labels(&s));
}
}
texts
})
@@ -5997,7 +6047,7 @@ mod tests {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
ui.show(Page::Pvp);
let (widgets, _, _, _) = ui.layout(&s, 480.0, 480.0);
let extra = widgets
.iter()
@@ -6007,7 +6057,7 @@ mod tests {
WKind::Button {
act: Act::Extra(item),
..
} if !matches!(item, crate::extras::Item::SortKey)
} if item.key_name().is_none()
)
})
.unwrap();
@@ -6053,19 +6103,17 @@ mod tests {
#[test]
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
let english = || lang::Language::load(&[], "en_us");
// Without the mod, Extras says where it is installed (a note, not a second way into
// Options > Mods).
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
)),
"Extras has no hint for the Zoom mod"
labels.iter().any(|l| l == "Zoom: install from Mods"),
"Extras has no hint for the Zoom mod: {labels:?}"
);
// Built in, Zoom's settings live on its page: Extras opens that page.
s.mods.installed.push(crate::mod_list::Installed {
@@ -6079,30 +6127,28 @@ mod tests {
buttons: Vec::new(),
}),
});
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ModSettings(0),
label,
..
} if label.plain() == "Settings"
)),
"Extras doesn't open the Zoom mod's settings"
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
assert!(labels.iter().any(|l| l == "Settings"), "{labels:?}");
// The mod's switch is beside it, so Extras keeps the Zoom setting it had.
assert!(labels.iter().any(|l| l.starts_with("Zoom: ")), "{labels:?}");
let size = (480.0, 480.0);
let on = s.mods.is_on(0);
let at = ui.extras.point_at(&s, size, ExtraRow::Zoom(0));
assert_eq!(
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
vec![Event::ButtonClick, Event::Changed]
);
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ToggleMod(0),
label,
..
} if label.plain().starts_with("Zoom")
)),
"Extras doesn't switch the Zoom mod"
assert_ne!(s.mods.is_on(0), on);
let at = ui.extras.point_at(&s, size, ExtraRow::ZoomSettings(0));
assert_eq!(
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
vec![
Event::ButtonClick,
Event::ButtonClick,
Event::NativeSettings(0)
]
);
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
@@ -6446,32 +6492,27 @@ mod tests {
);
assert_eq!(ui.page(), page);
}
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
assert_eq!(
widgets
.iter()
.filter(|w| matches!(
w.kind,
WKind::Button {
act: Act::Extra(_),
..
}
))
.count(),
crate::extras::rows().iter().filter(|r| r.is_ok()).count()
ui.extras.row_labels(&s).len(),
// And the note about the Zoom mod over them.
1 + crate::extras::rows().iter().filter(|r| r.is_ok()).count()
);
let press = |ui: &mut OptionsUi, s: &mut Settings, item: crate::extras::Item| {
let at = ui.extras.point_at(s, size, ExtraRow::Item(item));
ui.click(s, &atlas, at.0, at.1, size, [960, 960], false, false)
};
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Sort)),
press(&mut ui, &mut s, crate::extras::Item::Sort),
vec![Event::ButtonClick, Event::Changed]
);
assert!(s.extras.sort);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Pregen)),
press(&mut ui, &mut s, crate::extras::Item::Pregen),
vec![Event::ButtonClick, Event::Changed]
);
assert!(s.extras.pregen);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::SortKey)),
press(&mut ui, &mut s, crate::extras::Item::SortKey),
vec![Event::ButtonClick]
);
assert_eq!(ui.selected_key, Some(crate::extras::SORT_KEY));
@@ -6484,12 +6525,6 @@ mod tests {
crate::extras::Extras::parse(&s.extras.serialize()),
s.extras
);
// In a window too short for the page, the wheel scrolls to its end.
let short = (480.0, 180.0);
ui.scroll(&s, short, -1000.0);
let (_, top, bottom, content) = ui.layout(&s, short.0, short.1);
assert!(content > bottom - top);
assert_eq!(ui.scroll, content - (bottom - top));
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None),
vec![Event::Changed]
@@ -6497,6 +6532,24 @@ mod tests {
assert_eq!(ui.page(), Page::Horizon);
}
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
#[test]
fn the_wheel_and_the_stick_scroll_a_page_on_the_shell_alike() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
// Too short for the page.
let short = (480.0, 180.0);
ui.wheel(&mut s, short, (0.0, 0.0), [960, 360], -1.0);
let one = ui.extras.scroll_amount();
assert!(one > 0.0);
ui.scroll_at(&s, short, (0.0, 0.0), -1.0);
assert_eq!(ui.extras.scroll_amount(), 2.0 * one);
ui.scroll_at(&s, short, (0.0, 0.0), 2.0);
assert_eq!(ui.extras.scroll_amount(), 0.0);
}
#[test]
fn classic_graphics_presets_set_the_distances() {
let atlas = atlas();
@@ -7327,7 +7380,7 @@ mod tests {
/// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label
/// fits its button: "Keep channels for UI sounds: Off" did not.
#[test]
fn extras_labels_fit_their_buttons() {
fn pvp_labels_fit_their_buttons() {
// Vanilla's font: 6 px a glyph, a few narrower ones.
let width = |t: &str| {
t.chars()
@@ -7342,9 +7395,9 @@ mod tests {
};
let s = Settings::default();
let mut ui = OptionsUi::default();
for page in [Page::Extras, Page::Pvp] {
ui.stack = vec![Page::Main, page];
ui.wide_extras = wide_extras(&s, page, width);
{
ui.stack = vec![Page::Main, Page::Pvp];
ui.wide_extras = wide_extras(&s, width);
let rows = ui.list_rows(&s);
let buttons: Vec<&Widget> = rows
.iter()
@@ -7358,20 +7411,11 @@ mod tests {
continue;
};
let label = label.plain();
assert!(width(&label) <= b.w - 4.0, "{page:?}: {label} on {}", b.w);
assert!(width(&label) <= b.w - 4.0, "{label} on {}", b.w);
}
// Short ones keep vanilla's two to a row.
assert!(buttons.iter().any(|b| b.w == 150.0), "{page:?}");
assert!(buttons.iter().any(|b| b.w == 150.0));
}
ui.stack = vec![Page::Main, Page::Extras];
ui.wide_extras = wide_extras(&s, Page::Extras, width);
for item in [
crate::extras::Item::UiSoundReserve,
crate::extras::Item::WheelCyclesSettings,
] {
assert!(ui.wide_extras.contains(&item), "{item:?}");
}
assert!(!ui.wide_extras.contains(&crate::extras::Item::AutoReconnect));
}
/// Every setting a player reaches, from the Options screen through every button that opens
@@ -7787,20 +7831,9 @@ mod tests {
s.extras.wheel_cycles_settings = true;
ui.show(Page::Extras);
let size = (480.0, 600.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let sort_wd = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::Sort),
..
}
)
})
.unwrap();
let cursor = (sort_wd.x + 5.0, sort_wd.y + 5.0);
let cursor = ui
.extras
.point_at(&s, size, ExtraRow::Item(crate::extras::Item::Sort));
assert!(!s.extras.sort);
ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
assert!(s.extras.sort);
@@ -8283,55 +8316,5 @@ mod tests {
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 0);
ui.shift = false;
// Also verify Act::Extra on Page::Extras
ui.show(Page::Extras);
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
if let Some(extra_btn) = widgets.iter().find(|wd| {
matches!(
wd.kind,
WKind::Button {
active: true,
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
}) {
// Extras has enough rows to scroll: click the cycle button inside
// the list's viewport, as a player would after scrolling to it.
ui.scroll = (extra_btn.y - top).max(0.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let extra_btn = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
})
.unwrap();
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
// Not chosen, nothing owned: shows Hidden.
assert_eq!(s.extras.cosmetics_others, None);
assert_eq!(s.extras.effective_others(), Others::Hidden);
// Forward (wraps to Shown), now a choice
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
// Backward with shift
ui.shift = true;
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
// Backward again
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
ui.shift = false;
}
}
}
File diff suppressed because it is too large. Load diff
@@ -644,6 +644,7 @@ impl ShaderUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
+5 -4
View File
@@ -147,19 +147,20 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice
sunset) behind the hero. Light and dark follow the system, with a toggle. A giant wordmark in
pixels sits in the footer.
- **Game**: vanilla's screens. The on-screen keyboard is a rounded plate of keys in the page
colour, the focused key in the accent. Horizon's graphics settings screen takes the Blocks
palette below in a lighter form (`graphics_style.rs`).
colour, the focused key in the accent. Horizon's graphics, controller and Extras settings
screens take the Blocks palette below in a lighter form (`graphics_style.rs`; Extras through
the shared `settings_shell.rs`).
## Blocks
A blocky, Minecraft-like look in Horizon's purple, for the website, the redesigned launcher and
the game's graphics settings screen:
the game's graphics, controller and Extras settings screens:
Minecraft's inventory rather than a document. Its colours are `horizon_theme::Blocks`
(`BLOCKS_LIGHT`, `BLOCKS_DARK`), which `tokens.css` carries under the same names as before plus
`--panel`, `--raised`, `--button`, `--well`, `--bevel`, `--lip`, `--outline` and the `--face`
of the main action. The game's other menus keep `LIGHT` and `DARK`.
In the game the graphics settings screen draws the Blocks palette in GUI pixels, lighter than the
In the game these settings screens draw the Blocks palette in GUI pixels, lighter than the
website's blocks so the world stays in view: square panels of the page colour, see-through, with a
1 px edge and no bevel or lip, flat controls, the violet face on Done, and vanilla's font rather
than Pixelify. Hover and focus are vanilla's white edge.
+3 -3
View File
@@ -20,10 +20,10 @@ again restores vanilla's behaviour completely.
| Sort order and its clicks | `crates/horizon-client/src/inv_sort.rs` (pure, unit tested) |
| The music page's rows, and the pools it filters | `options_ui.rs` (`Page::Music`), `crates/horizon-client/src/music.rs` (`track_groups`, `MusicManager::set_off`), `sound_events.rs` (`sound_except`) |
| Cosmetics (show mine, show others'), off with their flag | `extras.rs`, `crates/horizon-client/src/game/cosmetics.rs` ([cosmetics.md](cosmetics.md)) |
| The settings page | `options_ui.rs` (`Page::Extras`): **Options → Horizon Settings... → Extras...**; fullbright, dynamic lights and hiding block-break particles change the picture, so they are on the graphics screen's Quality page instead (`graphics_ui.rs`, **Options → Video Settings...**) |
| The settings page | `extras_ui.rs` (`Page::Extras`, on `settings_shell.rs`): **Options → Horizon Settings... → Extras...**; fullbright, dynamic lights and hiding block-break particles change the picture, so they are on the graphics screen's Quality page instead (`graphics_ui.rs`, **Options → Video Settings...**) |
The settings are grouped by extra, under a header each, two buttons to a row as
vanilla's options lists are, and each says what it does in its tooltip. An
The settings are grouped by extra, under a header each, one to a row, and the strip
under the list says what the row in focus or under the pointer does. An
extra's own settings (its key, corner, size...) are greyed out while its switch
is off (`Item::parent`).
+99
View File
@@ -34,6 +34,8 @@ Cases:
- pause_icon: the pause menu's icons are targets, and A presses one.
- studio_rows: the D-pad walks Replay Studio's recordings to its buttons, and B leaves.
- erase_checkbox: the D-pad reaches Optimize World's Erase Cached Data box, and A ticks it.
- extras_shell: Horizon Extras on the settings shell: the D-pad walks its rows and scrolls through the hidden ones, past
a greyed row, the right stick scrolls it, A changes and saves a toggle, the strip follows the row, B leaves.
Needs built fast binaries (`cargo build --profile fast -p horizon-client -p horizon-client-lib --bins`)
and prepared resources in .local/horizon-client/26.3. Exit status 0 when every case passes.
"""
@@ -1492,6 +1494,102 @@ TARGETS_WAIT = 5
STUDIO_RECORDINGS, STUDIO_BUTTONS = 12, 3
EXTRAS_ABOUT = 'Optional additions, all off by default'
AUTO_RECONNECT = 'Auto-reconnect'
AUTO_RECONNECT_ABOUT = 'Rejoins a server after a lost connection or a restart'
FIRST_EXTRA = 'Own name tag'
LAST_MENU_EXTRA = 'Resource pack browser'
GREYED_EXTRA = 'Sort key'
ROW_WALK = 40
def label_of(game, text):
"""The widget whose label has this in it, or None while the list hides it."""
return next((w for w in game.widgets() if text in w.get('label', '')), None)
def ring_on(game, rect):
"""Whether a white ring is drawn along the top of this rectangle, by the pixels: state can't show a stale one."""
path = game.home / 'ring.png'
game.ctl('screenshot', path, timeout=120)
rows, width, channels = png_rows(path)
scale = width / game.ctl('ui')['gui_size'][0]
x, y, w, _ = rect
pixel = rows[int((y + 0.5) * scale)][int((x + w / 2) * scale) * channels:][:3]
return pixel == bytes((255, 255, 255))
def extras_saved(game):
path = game.game_home / 'horizon-extras.txt'
return path.read_text() if path.exists() else ''
def extras_shell(args, name):
game = Game(args, name)
check = Check()
try:
game.press_through('west')
game.ctl('options', 'extras')
time.sleep(SETTLE)
game.wait_targets()
check(declared(game).get('Done') is True, f'Extras declares {declared(game)}')
check(game.menu()['undeclared'] == [], f"undeclared: {game.menu()['undeclared']}")
check(has_label(game, EXTRAS_ABOUT), f'the strip does not say what the page holds: {labels(game)}')
check(label_of(game, LAST_MENU_EXTRA) is None, 'the page fits: this case cannot see it scroll')
shot(game, 'extras-open')
# From the search field across to the first row, then down: a greyed row is stepped over, hidden rows scroll in.
game.pad('right')
check(FIRST_EXTRA in (game.focused_label() or ''), f'the D-pad never reached {FIRST_EXTRA}: {game.focused_label()}')
# A presses with a click: the row it pressed doesn't stay lit once the focus moves on.
game.pad('south')
pressed = game.focus()
game.pad('down')
check(game.focus() != pressed, 'the D-pad did not leave the row A pressed')
check(not ring_on(game, pressed), f'the row A pressed keeps a ring at {pressed} with the focus gone')
reached = {game.focused_label()}
for _ in range(ROW_WALK):
game.pad('down')
reached.add(game.focused_label())
if LAST_MENU_EXTRA in (game.focused_label() or ''):
break
check(any(LAST_MENU_EXTRA in (r or '') for r in reached), f'the D-pad never scrolled to the last rows: {reached}')
check(not any(GREYED_EXTRA in (r or '') for r in reached), f'the focus landed on a greyed row: {reached}')
shot(game, 'extras-scrolled')
# Back up the list to a row near its top; the right stick moves the list and the focus stays on it.
for _ in range(ROW_WALK):
if AUTO_RECONNECT in (game.focused_label() or ''):
break
game.pad('up')
label = game.focused_label()
check(AUTO_RECONNECT in (label or ''), f'the D-pad never came back to {AUTO_RECONNECT}: {label}')
check(has_label(game, AUTO_RECONNECT_ABOUT), f'the strip does not follow the row: {labels(game)}')
check(AUTO_RECONNECT in (label or '') and label.endswith('Off'), f'{AUTO_RECONNECT} reads {label}')
shot(game, 'extras-described')
y = game.focus()[1]
game.stick()
moved = game.focus()
check(game.focused_label() == label, f'the stick moved the focus from {label} to {game.focused_label()}')
check(moved and moved[1] < y, f'the stick did not scroll the list: {y} to {moved}')
# A changes the toggle: what the row reads, and the file it is saved in.
check('autoReconnect:true' not in extras_saved(game), 'Auto-reconnect began on')
game.pad('south')
label = game.focused_label()
check(label and label.endswith('On'), f'A left {AUTO_RECONNECT} as {label}')
check('autoReconnect:true' in extras_saved(game), 'the toggle was not saved to horizon-extras.txt')
shot(game, 'extras-toggled')
# B presses Done.
game.pad('east')
game.wait_targets()
check(not has_label(game, AUTO_RECONNECT), f'B did not leave Extras: {labels(game)}')
finally:
game.quit()
return check.failures
def check_has_targets(game, check, screen):
"""A focus-mode screen draws a target soon after it opens; pointer screens (packs) use the crosshair by design."""
end = time.time() + TARGETS_WAIT
@@ -2036,6 +2134,7 @@ CASES = {'idle_a': idle_a, 'idle_b': idle_b, 'idle_x': idle_x, 'release': releas
'every_screen_has_targets': every_screen_has_targets,
'studio_rows': studio_rows, 'pause_icon': pause_icon,
'erase_checkbox': erase_checkbox,
'extras_shell': extras_shell,
'glyph_art': glyph_art,
'glyph_art_world': glyph_art_world,
'key_row_a_x': key_row_a_x,