Compare commits

..
Author SHA1 Message Date
Ludovic Marie 937cadf658 Merge branch 'main' into split/pages-fields 2026-11-11 19:38:59 +01:00
Horizon agentandClaude Opus 5.5 92dab6cafc Pages v2: text fields, and a page in the title screen's Friends icon slot
horizon_hooks::pages gains Row::Field (Row::field(label, text).max(n)),
vanilla's EditBox on a page drawn as its own screen, with Act::Edit (each
change, before any other act or redraw reaches the page) and Act::Submit
(Enter); Place::Title, the title screen's Friends icon slot, unchanged in
place and look; Page::back, the label of the button that leaves such a
page; and pages::connect, for a Title page to join a server.

The icon opens the first Title page as multiplayer::Screen::Page, a form
laid out as Join a Friend (multiplayer/page.rs); its fields go through the
title screen's caret like every field of its own (focus, typing, caret
keys, selection, copy, paste, the max length), Tab walks fields then
buttons. Without a Title page the icon opens Join a Friend while a
resolver is registered, else it stays inactive. The options shell skips
fields. title.rs loses lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:11:27 +01:00
11 changed files with 592 additions and 281 deletions

No files matched your search

+6 -19
View File
@@ -439,33 +439,20 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack,
stack: String::new(),
thread: thread_name(side),
side,
details,
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -165,7 +165,7 @@ impl OptionsUi {
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) => continue,
added::Row::Space(_) | added::Row::Field { .. } => continue,
added::Row::Button {
label,
active,
@@ -44,8 +44,6 @@ mod transfer;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
/// The thread running the ticks, whose stack the report carries.
server: horizon_threads::stack_dump::Watched,
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
@@ -55,11 +53,9 @@ struct Watchdog {
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
/// Started from the thread that runs the ticks.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
server: horizon_threads::stack_dump::Watched::current(),
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
@@ -88,8 +84,6 @@ impl Watchdog {
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
// ServerWatchdog: the server thread's stack first, while it is still stuck.
let stack = self.server.stack(Duration::from_secs(5));
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
@@ -112,7 +106,6 @@ impl Watchdog {
std::path::Path::new("."),
"server",
message,
stack,
) {
Some(path) => log_from(
"Server Watchdog",
+41
View File
@@ -7,6 +7,7 @@ use crate::lan::discovery::Scanner;
use crate::text::{DARK_GRAY, GRAY, Text, format_translation};
use crate::title::confirm_top;
use horizon_protocol::servers::{self, ServerEntry};
pub use page::Form;
use std::{
collections::HashMap,
path::{Path, PathBuf},
@@ -138,6 +139,8 @@ pub enum Screen {
relay: String,
focus: u8,
},
/// A page a crate registered at the title screen's Friends icon, as a form.
Page(Form),
Disconnected {
reason: String,
/// Auto-reconnect's next attempt, until it is cancelled.
@@ -146,6 +149,21 @@ pub enum Screen {
lines: std::cell::Cell<usize>,
},
}
mod page;
/// What the title screen's Friends icon opens: the page a crate registered there
/// (`Place::Title`), else Join a Friend when an invite code can be joined. None: it is inactive.
pub fn title_entry() -> Option<Screen> {
match page::title_page() {
Some(page) => Some(Screen::Page(Form::open(page))),
None => crate::net::joins_friends().then(|| Screen::friend(true)),
}
}
/// Whether the title screen's Friends icon opens something ([`title_entry`]).
pub fn has_title_entry() -> bool {
page::title_page().is_some() || crate::net::joins_friends()
}
/// What a click asks the title screen to do.
pub enum Outcome {
None,
@@ -176,6 +194,7 @@ impl Screen {
Self::Direct { .. } => "direct_connect",
Self::ConfirmDelete { .. } => "server_delete",
Self::Friend { .. } => "friends_join",
Self::Page(form) => form.id(),
Self::Disconnected { .. } => "disconnected",
}
}
@@ -744,6 +763,7 @@ impl Screen {
53,
),
],
Self::Page(form) => form.buttons(w, h),
Self::Friend { code, .. } => vec![
b(
half(w) - 100.0,
@@ -864,6 +884,7 @@ impl Screen {
]),
)
}
Self::Page(form) => form.narration(),
Self::Friend { .. } => ScreenInfo::new(
"multiplayer/friend",
get("horizon.multiplayer.join_friend", "Join a Friend"),
@@ -1318,6 +1339,7 @@ impl Screen {
centered(batch, &line.plain(), top + 17.0 + i as f32 * 9.0, u32::MAX);
}
}
Self::Page(form) => form.draw(atlas, batch, caret, focused_at),
Self::Friend {
code, relay, focus, ..
} => {
@@ -1383,6 +1405,7 @@ impl Screen {
/// selected server or LAN game, as a click on it does.
pub fn confirm(&self) -> Outcome {
match self {
Self::Page(form) => form.confirm(),
Self::Direct { address } if servers::parse_address(address).is_some() => {
Outcome::Connect {
address: direct_address(address),
@@ -1431,6 +1454,9 @@ impl Screen {
saves: &Path,
) -> Outcome {
let (w, h) = size;
if let Self::Page(form) = self {
return form.click(id, x, y);
}
match (&mut *self, id) {
(
Self::List {
@@ -1823,6 +1849,7 @@ impl Screen {
Self::Friend { focus: 0, .. } => 64.0,
Self::Friend { focus: 1, .. } => 105.0,
Self::Friend { .. } => return None,
Self::Page(form) => return form.focused_box(),
_ => return None,
};
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
@@ -1851,6 +1878,13 @@ impl Screen {
_ => None,
}
}
/// Whether a press off the buttons may land on one of its text fields.
pub fn has_fields(&self) -> bool {
matches!(
self,
Self::Edit { .. } | Self::Friend { .. } | Self::Page(_)
)
}
/// Join a friend, with the relay the player set before.
pub fn friend(from_title: bool) -> Self {
Self::Friend {
@@ -1910,6 +1944,9 @@ impl Screen {
}
/// The focused text field, its id and maximum length (copy and paste).
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
if let Self::Page(form) = self {
return form.focused_text();
}
let id = match self {
Self::Edit { focus: 0, .. } => SERVER_NAME,
Self::Edit { .. } => SERVER_ADDRESS,
@@ -1934,6 +1971,9 @@ impl Screen {
/// round to the first field. `button` is the focused button, if any; a field
/// has the focus (`focus` below 2) only while no button does.
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
if let Self::Page(form) = self {
return form.tab_dir(reverse, buttons, button);
}
let (Self::Edit { focus, .. } | Self::Friend { focus, .. }) = self else {
return;
};
@@ -1960,6 +2000,7 @@ impl Screen {
pub fn escape(&mut self, saves: &Path) -> bool {
match self {
Self::List { .. }
| Self::Page(_)
| Self::Friend {
from_title: true, ..
} => true,
@@ -0,0 +1,467 @@
//! A page a crate registers at `horizon_hooks::pages::Place::Title`, which the title screen's
//! Friends icon opens: a form drawn as Join a Friend and `ManageServerScreen` are. The title at
//! the top, then the rows from y 53 (a field's grey label, its 200-wide `EditBox` 11 below, the
//! next 41 lower; text 10 a line), and the page's buttons in a column under them from a quarter
//! of the height + 114, 24 apart, the one that leaves it last.
//!
//! The engine keeps each field's text while the form is open (the title screen's caret edits
//! it, as every field of its own) and tells the page each change before anything else reaches
//! it ([`Act::Edit`]), then Enter in a field ([`Act::Submit`]) and the presses. A page joins a
//! server with `horizon_hooks::pages::connect`.
use super::{ButtonSpec, Outcome, centered_x, half, quarter};
use crate::hud::{HudAtlas, HudBatch, rgba};
use horizon_hooks::pages::{self, Act, Page, Place, Row};
use std::cell::RefCell;
use std::time::Instant;
/// The first button's id; button `k` of the page is `FIRST + k`.
const FIRST: u8 = 100;
/// The text a line of the form wraps at.
const WIDTH: f32 = 300.0;
/// The page the title screen's Friends icon opens: the first registered there.
pub(super) fn title_page() -> Option<&'static dyn Page> {
pages::engine::all()
.iter()
.copied()
.find(|page| page.place() == Place::Title)
}
/// An open form: the page, and what the engine keeps while it is open.
#[derive(Clone)]
pub struct Form {
page: &'static dyn Page,
/// Each field's text, in the order the rows have them.
texts: Vec<String>,
/// The text the page last heard of each field.
sent: RefCell<Vec<String>>,
/// The focused field; `texts.len()` (or more) while a button has the focus.
pub(super) focus: usize,
/// Each field's box where the last draw put it.
boxes: RefCell<Vec<[f32; 4]>>,
}
impl std::fmt::Debug for Form {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Form")
.field("page", &self.page.id())
.field("texts", &self.texts)
.field("focus", &self.focus)
.finish()
}
}
/// A button of the form: its row (None: the one that leaves), label key, and whether it acts.
struct Button {
row: Option<usize>,
label: &'static str,
active: bool,
half: bool,
}
impl Form {
/// The form of `page`, its fields holding the text its rows start with, the first focused.
pub fn open(page: &'static dyn Page) -> Self {
let mut rows = Vec::new();
page.rows(&mut rows);
let texts: Vec<String> = rows
.iter()
.filter_map(|row| match row {
Row::Field { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
Self {
page,
sent: RefCell::new(texts.clone()),
boxes: RefCell::new(vec![[0.0; 4]; texts.len()]),
texts,
focus: 0,
}
}
pub fn id(&self) -> &'static str {
self.page.id()
}
/// The page's rows, after it heard every change to its fields.
fn rows(&self) -> Vec<Row> {
let mut rows = Vec::new();
self.page.rows(&mut rows);
let fields = field_rows(&rows);
let mut sent = self.sent.borrow_mut();
let mut told = false;
for ((text, sent), row) in self.texts.iter().zip(sent.iter_mut()).zip(fields) {
if text != sent {
sent.clone_from(text);
self.page.act(row, Act::Edit(text.clone()));
told = true;
}
}
if told {
rows.clear();
self.page.rows(&mut rows);
}
rows
}
fn buttons_of(rows: &[Row], back: &'static str) -> Vec<Button> {
let mut buttons: Vec<Button> = rows
.iter()
.enumerate()
.filter_map(|(r, row)| match row {
Row::Button {
label,
active,
half,
..
} => Some(Button {
row: Some(r),
label,
active: *active,
half: *half,
}),
_ => None,
})
.collect();
buttons.push(Button {
row: None,
label: back,
active: true,
half: false,
});
buttons
}
/// The id of the button that leaves: Cancel's or Done's, for the controller.
fn back_id(&self) -> u8 {
if self.page.back() == "gui.cancel" {
57
} else {
50
}
}
pub fn buttons(&self, w: f32, h: f32) -> Vec<ButtonSpec> {
let rows = self.rows();
let cx = half(w);
let mut y = quarter(h) + 114.0;
let mut beside = false;
let mut out = Vec::new();
for (k, b) in Self::buttons_of(&rows, self.page.back())
.into_iter()
.enumerate()
{
let id = match b.row {
Some(_) => FIRST + k as u8,
None => self.back_id(),
};
let label = crate::lang::get(b.label);
let (x, bw) = match (b.half, beside) {
(true, true) => {
y -= 24.0;
beside = false;
(cx + 2.0, 98.0)
}
(true, false) => {
beside = true;
(cx - 100.0, 98.0)
}
(false, _) => {
beside = false;
(cx - 100.0, 200.0)
}
};
out.push((x, y, bw, label, b.active, id));
y += 24.0;
}
out
}
pub fn draw(
&self,
atlas: &HudAtlas,
batch: &mut HudBatch,
caret: &mut crate::ui::Caret,
focused_at: Instant,
) {
let w = batch.width;
let rows = self.rows();
let title = crate::lang::get(self.page.title());
let tw = HudBatch::text_width(atlas, &title);
batch.text(atlas, &title, centered_x(w, tw), 20.0, u32::MAX, true);
let mut y = 53.0;
let mut field = 0;
let mut boxes = self.boxes.borrow_mut();
for (r, row) in rows.iter().enumerate() {
let (text, color) = match row {
Row::Field { label, max, .. } => {
let Some(value) = self.texts.get(field) else {
break;
};
super::Screen::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get(label),
value,
("page.field", r),
*max,
(y, y + 11.0),
self.focus == field,
);
boxes[field] = [half(w) - 100.0, y + 11.0, 200.0, 20.0];
field += 1;
y += 41.0;
continue;
}
Row::Space(px) => {
y += f32::from(*px);
continue;
}
Row::Heading(key) => (crate::lang::get(key), u32::MAX),
Row::Note(text) => (text.clone(), rgba(160, 160, 160, 255)),
Row::Text { text, quiet, .. } => {
let grey = rgba(160, 160, 160, 255);
(text.clone(), if *quiet { grey } else { u32::MAX })
}
_ => continue,
};
for line in super::wrap(&text, WIDTH, atlas) {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, centered_x(w, lw), y, color, true);
y += 10.0;
}
}
}
pub fn narration(&self) -> crate::widgets::ScreenInfo {
crate::widgets::ScreenInfo::new(self.page.id(), crate::lang::get(self.page.title()))
}
/// A press on button `id`, or on a field (`id` None).
pub fn click(&mut self, id: Option<u8>, x: f32, y: f32) -> Outcome {
match id {
Some(id) if id == self.back_id() => Outcome::Back,
Some(id) if id >= FIRST => {
let rows = self.rows();
let buttons = Self::buttons_of(&rows, self.page.back());
match buttons.get(usize::from(id - FIRST)) {
Some(Button {
row: Some(r),
active: true,
..
}) => self.act(*r, Act::Press),
_ => Outcome::None,
}
}
Some(_) => Outcome::None,
None => {
let hit = self
.boxes
.borrow()
.iter()
.position(|&[bx, by, bw, bh]| crate::ui::hovered((x, y), bx, by, bw, bh));
if let Some(field) = hit {
self.focus = field;
}
Outcome::None
}
}
}
/// Enter: the focused field submits.
pub fn confirm(&self) -> Outcome {
let rows = self.rows();
match field_rows(&rows).nth(self.focus) {
Some(r) => self.act(r, Act::Submit),
None => Outcome::None,
}
}
/// The page acts; a server it asked to join is joined.
fn act(&self, row: usize, act: Act) -> Outcome {
self.page.act(row, act);
match pages::engine::take_connect() {
Some(address) => Outcome::Connect {
address,
lan: false,
},
None => Outcome::None,
}
}
pub fn focused_box(&self) -> Option<[f32; 4]> {
self.boxes.borrow().get(self.focus).copied()
}
/// The focused field, its id and maximum length.
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
let mut rows = Vec::new();
self.page.rows(&mut rows);
let (r, max) = rows
.iter()
.enumerate()
.filter_map(|(r, row)| match row {
Row::Field { max, .. } => Some((r, *max)),
_ => None,
})
.nth(self.focus)?;
let text = self.texts.get_mut(self.focus)?;
Some((("page.field", r), text, max))
}
/// Tab through the fields, then the enabled `buttons`, then round to the first field.
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
let fields = self.texts.len();
let stops = fields + buttons.len();
if stops == 0 {
return;
}
let at = match *button {
Some(id) => buttons.iter().position(|&b| b == id).map(|i| fields + i),
None => Some(self.focus.min(fields.saturating_sub(1))),
}
.unwrap_or(0);
let next = if reverse {
(at + stops - 1) % stops
} else {
(at + 1) % stops
};
if next < fields {
self.focus = next;
*button = None;
} else {
self.focus = fields;
*button = Some(buttons[next - fields]);
}
}
}
/// The rows that are fields, in order.
fn field_rows(rows: &[Row]) -> impl Iterator<Item = usize> + '_ {
rows.iter()
.enumerate()
.filter(|(_, row)| matches!(row, Row::Field { .. }))
.map(|(r, _)| r)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A join form: a code and a relay, Join while the code has 4 characters or more.
struct Join {
heard: Mutex<Vec<(usize, Act)>>,
code: Mutex<String>,
}
impl Page for Join {
fn id(&self) -> &'static str {
"test.join"
}
fn title(&self) -> &'static str {
"horizon.multiplayer.join_friend"
}
fn place(&self) -> Place {
Place::Title
}
fn rows(&self, rows: &mut Vec<Row>) {
let code = self.code.lock().unwrap().clone();
rows.push(Row::field("horizon.multiplayer.invite_code", code.clone()).max(8));
rows.push(Row::field(
"horizon.multiplayer.relay",
"relay.example:25599",
));
rows.push(Row::button("horizon.multiplayer.join").active(code.len() >= 4));
}
fn act(&self, row: usize, act: Act) {
if let (0, Act::Edit(text)) = (row, &act) {
text.clone_into(&mut self.code.lock().unwrap());
}
if matches!((row, &act), (2, Act::Press) | (0, Act::Submit)) {
pages::connect(format!("friend:{}", self.code.lock().unwrap()));
}
self.heard.lock().unwrap().push((row, act));
}
fn back(&self) -> &'static str {
"gui.cancel"
}
}
fn join() -> &'static Join {
Box::leak(Box::new(Join {
heard: Mutex::new(Vec::new()),
code: Mutex::new(String::new()),
}))
}
#[test]
fn a_form_lays_out_as_join_a_friend() {
let form = Form::open(join());
let buttons = form.buttons(427.0, 240.0);
// Join at a quarter of the height + 114, Cancel 24 below, both 200 wide and centred.
assert_eq!(
buttons
.iter()
.map(|(x, y, w, _, on, id)| (*x, *y, *w, *on, *id))
.collect::<Vec<_>>(),
[
(113.0, 174.0, 200.0, false, FIRST),
(113.0, 198.0, 200.0, true, 57)
]
);
assert_eq!(form.texts, ["", "relay.example:25599"]);
}
#[test]
fn typing_reaches_the_page_before_a_press_and_enter_submits() {
let page = join();
let mut form = Form::open(page);
let (id, text, max) = form.focused_text().unwrap();
assert_eq!((id, max), (("page.field", 0), 8));
text.push_str("AB");
// Join is greyed: the page heard "AB" before the buttons were laid out.
assert!(!form.buttons(427.0, 240.0)[0].4);
form.focused_text().unwrap().1.push_str("CD");
assert!(form.buttons(427.0, 240.0)[0].4);
match form.click(Some(FIRST), 0.0, 0.0) {
Outcome::Connect { address, lan } => {
assert_eq!((address.as_str(), lan), ("friend:ABCD", false))
}
_ => panic!("Join did not connect"),
}
match form.confirm() {
Outcome::Connect { address, .. } => assert_eq!(address, "friend:ABCD"),
_ => panic!("Enter did not submit"),
}
let heard = page.heard.lock().unwrap();
assert_eq!(
*heard,
[
(0, Act::Edit("AB".into())),
(0, Act::Edit("ABCD".into())),
(2, Act::Press),
(0, Act::Submit)
]
);
assert!(matches!(form.click(Some(57), 0.0, 0.0), Outcome::Back));
}
#[test]
fn tab_goes_through_the_fields_then_the_buttons() {
let mut form = Form::open(join());
let mut button = None;
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (1, None));
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (2, Some(57)));
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (0, None));
form.tab_dir(true, &[57], &mut button);
assert_eq!(button, Some(57));
assert!(form.focused_text().is_none());
}
}
+5 -10
View File
@@ -1940,8 +1940,8 @@ impl Title {
)
.does(crate::widgets::Action::Exit),
];
// CommonButtons' icon buttons; Join a Friend (an invite code) sits behind
// vanilla's Friends button, inactive without a resolver (`net::joins_friends`).
// CommonButtons' icon buttons: vanilla's Friends button opens a crate's page or
// Join a Friend, inactive with neither (`multiplayer::title_entry`).
for (i, (key, plain, id, icon)) in [
("gui.friends.open", "Friends", 5, "friends/friends"),
("options.language", "Language...", 6, "icon/language"),
@@ -1960,7 +1960,7 @@ impl Title {
y + 72.0,
20.0,
&lang::get_or(key, plain),
id != 5 || crate::net::joins_friends(),
id != 5 || crate::multiplayer::has_title_entry(),
id,
);
button.icon = Some((icon, 15.0));
@@ -3658,12 +3658,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
.map(|b| b.id);
if hit.is_some() {
self.focused_button = hit;
} else if matches!(
self.mode,
Mode::Multiplayer(
crate::multiplayer::Screen::Edit { .. } | crate::multiplayer::Screen::Friend { .. }
)
) {
} else if matches!(&self.mode, Mode::Multiplayer(screen) if screen.has_fields()) {
// A press on a text field takes the focus from the button.
self.focused_button = None;
}
@@ -3782,7 +3777,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
crate::multiplayer::Outcome::None => Action::None,
},
(Mode::Main, Some(5)) => {
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::friend(true));
self.mode = crate::multiplayer::title_entry().map_or(Mode::Main, Mode::Multiplayer);
Action::None
}
(Mode::Onboarding(_), Some(onboarding::SOUNDS)) => Action::Sounds,
+55
View File
@@ -19,6 +19,11 @@ pub enum Place {
/// start a group of their own after the page's. A [`Row::Heading`] starts another group. A
/// [`Row::Key`] shows its key there, without changing it: give a page of its own to a key.
Extras,
/// The title screen's Friends icon slot (vanilla's `CommonButtons.friends`, beside Language
/// and Accessibility): with a page registered here the icon opens the first one as a form of
/// its own, as Join a Friend is drawn ([`Row::Field`]). It shows heading, note, text, space,
/// field and button rows; the others belong in the options shell.
Title,
}
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
@@ -40,6 +45,11 @@ pub trait Page: Sync {
fn tooltip(&self, _row: usize) -> Option<Label> {
None
}
/// The label of the button the engine adds to leave a page drawn as its own screen: a
/// translation key, `gui.done` by default (`gui.cancel` for a form that does something).
fn back(&self) -> &'static str {
"gui.done"
}
}
/// A row of a page. Labels are translation keys.
@@ -85,6 +95,16 @@ pub enum Row {
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
Key { label: &'static str, key: String },
/// A text field ([`Row::field`]) on a page drawn as its own screen, as vanilla's `EditBox`:
/// its label above it, focus by click or Tab, typing, the caret keys, selection, copy and
/// paste, at most `max` characters. `text` is its value when the page opens; the engine then
/// keeps it, and sends each change ([`Act::Edit`]) and Enter ([`Act::Submit`]).
#[non_exhaustive]
Field {
label: &'static str,
text: String,
max: usize,
},
}
impl Row {
@@ -107,6 +127,23 @@ impl Row {
}
}
/// A text field starting with `text`, at most 32 characters (`EditBox`'s default).
pub fn field(label: &'static str, text: impl Into<String>) -> Self {
Self::Field {
label,
text: text.into(),
max: 32,
}
}
/// A text field taking at most `chars` characters.
pub fn max(mut self, chars: usize) -> Self {
if let Self::Field { max, .. } = &mut self {
*max = chars;
}
self
}
/// A button greyed out (`false`): drawn, never pressed.
pub fn active(mut self, on: bool) -> Self {
if let Self::Button { active, .. } = &mut self {
@@ -167,6 +204,19 @@ pub enum Act {
Slide(f32),
/// A key row got its new key: its key name (`key.keyboard.z`).
Bind(String),
/// A field's text changed: all of it.
Edit(String),
/// Enter in a field.
Submit,
}
/// The server a page asked to join ([`connect`]).
static CONNECT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// From a [`Place::Title`] page's [`Page::act`]: join the server at `address` as Direct
/// Connection does (the engine's addresses, `friend:<code>` among them), once the act returns.
pub fn connect(address: impl Into<String>) {
*CONNECT.lock().unwrap_or_else(|e| e.into_inner()) = Some(address.into());
}
static PAGES: Registry<dyn Page> = Registry::new();
@@ -185,4 +235,9 @@ pub mod engine {
pub fn all() -> &'static [&'static dyn Page] {
PAGES.all()
}
/// The server a page asked to join since the last call ([`connect`]).
pub fn take_connect() -> Option<String> {
CONNECT.lock().unwrap_or_else(|e| e.into_inner()).take()
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ pub mod settings {
/// Settings pages a crate adds to the options screens (docs/layers.md, "Platform registries").
pub mod pages {
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, connect, register};
}
/// The lang files with a crate's text, loaded with the game's own (docs/layers.md, "Platform
-1
View File
@@ -7,7 +7,6 @@ pub mod boot_paint;
pub mod heap_census;
pub mod jobs;
pub mod process_memory;
pub mod stack_dump;
use std::sync::{
OnceLock,
-240
View File
@@ -1,240 +0,0 @@
//! Another thread's stack, read from outside it: the server watchdog's crash report says what
//! the hung tick was doing, as vanilla's `ServerWatchdog` dumps the server thread's stack (#15349).
//!
//! On Linux, Android and Apple systems a signal interrupts the watched thread and its handler
//! captures that thread's own backtrace. The handler allocates, which a signal handler should not
//! do: ask only right before the process ends, as the watchdog does. A thread that never runs the
//! handler (stuck in the kernel, or deadlocked inside the handler) leaves the asker without an
//! answer once its wait is over.
use std::time::Duration;
/// A thread whose stack another thread can read ([`Watched::stack`]).
#[derive(Clone, Copy, Debug)]
pub struct Watched {
#[cfg_attr(
not(any(target_vendor = "apple", target_os = "linux", target_os = "android")),
allow(dead_code)
)]
thread: usize,
}
/// Why [`Watched::stack`] has no stack.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoStack {
/// This system can't interrupt another thread to read its stack.
Unsupported,
/// The thread did not answer within the wait (blocked in the kernel, for one).
NoAnswer,
}
impl std::fmt::Display for NoStack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
NoStack::Unsupported => "not available on this system",
NoStack::NoAnswer => "the thread did not answer",
})
}
}
impl Watched {
/// The calling thread. It must still run when [`Watched::stack`] is asked.
pub fn current() -> Self {
Self {
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
thread: unsafe { libc::pthread_self() } as usize,
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
thread: 0,
}
}
/// The thread's stack as `std::backtrace::Backtrace` prints it, starting at the frame the
/// thread was in (on Apple's arm64, at its caller), waiting at most `wait` for it.
pub fn stack(&self, wait: Duration) -> Result<String, NoStack> {
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
{
imp::stack(self.thread, wait).map(|b| after_handler(&b.to_string()))
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
{
let _ = wait;
Err(NoStack::Unsupported)
}
}
}
/// A backtrace's text without the frames the signal added: the capture's, the handler's and the
/// signal trampoline's, the one frame the system puts between the handler and the interrupted
/// code (named `__restore_rt`, `_sigtramp`, or after whatever symbol precedes it). Unchanged when
/// the handler's frame isn't found.
fn after_handler(text: &str) -> String {
fn frame_name(line: &str) -> Option<&str> {
let (n, name) = line.trim().split_once(": ")?;
n.chars().all(|c| c.is_ascii_digit()).then_some(name)
}
let lines: Vec<&str> = text.lines().collect();
let frames: Vec<usize> = (0..lines.len())
.filter(|&i| frame_name(lines[i]).is_some())
.collect();
let Some(handler) = frames.iter().position(|&i| {
frame_name(lines[i]).is_some_and(|n| n == "on_signal" || n.ends_with("::on_signal"))
}) else {
return text.to_owned();
};
let start = frames.get(handler + 2).copied().unwrap_or(lines.len());
lines[start..].iter().map(|l| format!("{l}\n")).collect()
}
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
mod imp {
use super::NoStack;
use std::{
backtrace::Backtrace,
sync::{
Mutex, OnceLock,
atomic::{AtomicPtr, Ordering},
},
time::{Duration, Instant},
};
/// Ignored when no handler is installed, and used by nothing else here (Go's runtime takes it
/// for the same reason): a stray one does nothing.
const SIGNAL: libc::c_int = libc::SIGURG;
/// The handler's answer, taken by the asker.
static ANSWER: AtomicPtr<Backtrace> = AtomicPtr::new(std::ptr::null_mut());
/// One question at a time.
static ASKING: Mutex<()> = Mutex::new(());
extern "C" fn on_signal(_: libc::c_int) {
let stack = Box::into_raw(Box::new(Backtrace::force_capture()));
// An answer nobody took (its asker gave up) is left as it is: freeing here could be
// the allocation that goes wrong.
ANSWER.store(stack, Ordering::Release);
}
fn installed() -> bool {
static INSTALLED: OnceLock<bool> = OnceLock::new();
*INSTALLED.get_or_init(|| unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
action.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut action.sa_mask);
libc::sigaction(SIGNAL, &action, std::ptr::null_mut()) == 0
})
}
fn take() -> Option<Backtrace> {
let answer = ANSWER.swap(std::ptr::null_mut(), Ordering::Acquire);
// SAFETY: only `on_signal` stores, a pointer from `Box::into_raw`, and the swap takes it once.
(!answer.is_null()).then(|| *unsafe { Box::from_raw(answer) })
}
pub(super) fn stack(thread: usize, wait: Duration) -> Result<Backtrace, NoStack> {
let _one = ASKING.lock().unwrap_or_else(|e| e.into_inner());
if !installed() {
return Err(NoStack::Unsupported);
}
// A late answer to an earlier question.
drop(take());
// SAFETY: `Watched::current`'s contract keeps the thread alive.
if unsafe { libc::pthread_kill(thread as libc::pthread_t, SIGNAL) } != 0 {
return Err(NoStack::NoAnswer);
}
let until = Instant::now() + wait;
loop {
if let Some(stack) = take() {
return Ok(stack);
}
if Instant::now() >= until {
return Err(NoStack::NoAnswer);
}
std::thread::sleep(Duration::from_millis(2));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
};
#[inline(never)]
fn spin(mut n: u64) -> u64 {
for _ in 0..1_000_000 {
n = std::hint::black_box(n.wrapping_mul(31).wrapping_add(7));
}
n
}
/// Its time goes to a call: Apple's arm64 unwinder loses the frame the signal interrupted
/// (it resumes from the return address), while every caller's frame stays.
#[inline(never)]
fn busy_in_a_named_frame(stop: &AtomicBool) {
let mut n = 0u64;
while !stop.load(Ordering::Relaxed) {
n = std::hint::black_box(spin)(n);
}
}
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
#[test]
fn reads_the_stack_of_a_busy_thread_from_another() {
let stop = Arc::new(AtomicBool::new(false));
let (tx, rx) = mpsc::channel();
let worker = {
let stop = stop.clone();
std::thread::spawn(move || {
tx.send(Watched::current()).unwrap();
busy_in_a_named_frame(&stop);
})
};
let watched = rx.recv().unwrap();
let stack = watched.stack(Duration::from_secs(10));
stop.store(true, Ordering::Relaxed);
worker.join().unwrap();
let stack = stack.unwrap();
let busy = stack.find("busy_in_a_named_frame");
assert!(
busy.is_some(),
"the busy thread's frame is in its stack:\n{stack}"
);
assert!(
!stack[..busy.unwrap()].contains("on_signal") && !stack.contains("Backtrace"),
"the stack starts where the thread was, not in the signal handler:\n{stack}"
);
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
#[test]
fn says_when_the_system_cannot_read_a_stack() {
assert_eq!(
Watched::current().stack(Duration::from_millis(10)),
Err(NoStack::Unsupported)
);
}
#[test]
fn drops_the_capture_handler_and_trampoline_frames() {
let text = " 0: std::backtrace::Backtrace::force_capture
at /rustc/library/std/src/backtrace.rs:312:9
1: horizon_threads::stack_dump::imp::on_signal
at ./src/stack_dump.rs:120:40
2: __os_lock_spin_lock
3: horizon_client::game::Game::tick_server
at ./src/game/levels.rs:10:5
4: main
";
assert_eq!(
after_handler(text),
" 3: horizon_client::game::Game::tick_server
at ./src/game/levels.rs:10:5
4: main
"
);
}
}
+16 -2
View File
@@ -363,7 +363,7 @@ pub trait Store: Sync {
A page lives in the options screen's shell, and its entry sits on one of the game's menus.
```rust
pub enum Place { HorizonSettings, Title, Pause }
pub enum Place { HorizonSettings, Pause, Extras, Title }
pub trait Page: Sync {
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
fn title(&self) -> &'static str; // translation key
@@ -371,6 +371,7 @@ pub trait Page: Sync {
fn rows(&self, rows: &mut Vec<Row>); // asked when the page is drawn, not every frame elsewhere
fn act(&self, row: usize, act: Act); // a click, a reverse click, a slider's value, a key
fn tooltip(&self, row: usize) -> Option<Label> { None } // hover text, may follow the value
fn back(&self) -> &'static str { "gui.done" } // the leave button of a page drawn as a screen
}
pub enum Row {
Heading(&'static str), // translation key
@@ -382,9 +383,11 @@ pub enum Row {
Key { label: &'static str, key: String }, // a binding: `key.keyboard.z`
Text { text: String, quiet: bool, groups: bool }, // Row::text(..): white or grey, wrapped
Space(u8), // GUI pixels, on a page drawn as a screen
Field { label: &'static str, text: String, max: usize }, // Row::field(..).max(n): an EditBox, on a page drawn as a screen
}
pub enum Label { Key(&'static str), Value(String) }
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
pub enum Act { Press, PressBack, Slide(f32), Bind(String), Edit(String), Submit }
pub fn connect(address: impl Into<String>); // from a Title page's act: join that server
```
- `HorizonSettings`: a row of the Horizon Settings page, after the engine's own pages. `Title` and `Pause`: a
@@ -395,6 +398,17 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
shell, text is notes and space is nothing.
- `Title` is the title screen's Friends icon slot (vanilla's `CommonButtons.friends`), not a new row: the icon
keeps its place and look at every GUI scale, and opens the first page registered there as a form drawn as Join a
Friend is (`multiplayer/page.rs`): the title, the rows from y 53 (a field's grey label, its 200-wide box 11
below, 41 a field), the buttons in a column from a quarter of the height + 114, the back button last
(`Page::back`, `gui.cancel` for a form). It shows heading, note, text, space, field and button rows. With no page
there the icon opens the engine's Join a Friend while an invite-code resolver is registered, else it is
inactive.
- `Field` is vanilla's `EditBox` on a page drawn as its own screen: focus by click or Tab, typing, the caret keys,
selection, copy and paste, at most `max` characters (32 by default). The engine keeps its text while the page is
open and sends each change (`Act::Edit`, before any other act or redraw reaches the page) and Enter
(`Act::Submit`). The options shell has no field.
- Rows are rebuilt when the page is drawn or acted on, like the engine's own pages: no cost while no page is
open.
- The engine keeps native mods' pages (`Page::NativeMod`) as they are. Both kinds sit in one module that