Merge pull request #1734 from ludovic111/feat/server-dialog-screen

🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
This commit is contained in:
Ludovic Marie
2026-10-26 04:17:30 +01:00
committed by GitHub
12 changed files with 1928 additions and 5 deletions
+794
View File
@@ -0,0 +1,794 @@
//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
//! decodes from a `show_dialog` packet, the values its inputs hold, and the
//! click event an action makes of them (`Action.createAction`).
use crate::text::Text;
use horizon_world::nbt::Tag;
use serde_json::{Value, json};
/// `CommonDialogData` and the type's own fields.
#[derive(Clone, Debug)]
pub struct Dialog {
pub kind: Kind,
pub title: Text,
pub external_title: Option<Text>,
pub can_close_with_escape: bool,
pub pause: bool,
pub after_action: AfterAction,
pub body: Vec<Body>,
pub inputs: Vec<Input>,
}
/// The dialog types (`DialogTypes`).
#[derive(Clone, Debug)]
pub enum Kind {
/// `NoticeDialog`: one button, "OK" when the server names none.
Notice(Button),
Confirmation {
yes: Button,
no: Button,
},
MultiAction {
actions: Vec<Button>,
exit: Option<Button>,
columns: i32,
},
ServerLinks {
exit: Option<Button>,
columns: i32,
button_width: i32,
},
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
DialogList {
dialogs: Tag,
exit: Option<Button>,
columns: i32,
button_width: i32,
},
}
/// `DialogAction`: the screen after an action runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AfterAction {
Close,
None,
WaitForResponse,
}
/// `ActionButton`: `CommonButtonData` and what it does.
#[derive(Clone, Debug)]
pub struct Button {
pub label: Text,
pub tooltip: Option<Text>,
pub width: i32,
pub action: Option<Action>,
}
/// `Action`: a fixed click event, or one made from the inputs' values.
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
/// `StaticAction`: a click event as it is.
Static(Click),
/// `CommandTemplate`: a command with `$(key)` replaced by input values.
RunCommand(String),
/// `CustomAll`: a custom click event whose payload is `additions` plus
/// every input's value under its key.
Custom { id: String, additions: Option<Tag> },
}
/// `ClickEvent`, the kinds a server may send (`isAllowedFromServer`).
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
OpenUrl(String),
RunCommand(String),
SuggestCommand(String),
/// The dialog: an id of the `minecraft:dialog` registry or the dialog itself.
ShowDialog(Tag),
ChangePage(i32),
CopyToClipboard(String),
Custom {
id: String,
payload: Option<Tag>,
},
}
/// `DialogBody`.
#[derive(Clone, Debug)]
pub enum Body {
/// `PlainMessage`: centred text wrapped to `width`.
Message { contents: Text, width: i32 },
/// `ItemBody`: the item as `ItemStackTemplate` sent it.
Item {
item: Tag,
description: Option<(Text, i32)>,
show_decorations: bool,
show_tooltip: bool,
width: i32,
height: i32,
},
}
/// `Input`: the key its value goes by, and its control.
#[derive(Clone, Debug)]
pub struct Input {
pub key: String,
pub control: Control,
}
/// `InputControl`.
#[derive(Clone, Debug)]
pub enum Control {
Text {
width: i32,
label: Text,
label_visible: bool,
initial: String,
max_length: i32,
/// `MultilineOptions`: the most lines and the height, when set.
multiline: Option<(Option<i32>, Option<i32>)>,
},
Boolean {
label: Text,
initial: bool,
on_true: String,
on_false: String,
},
SingleOption {
width: i32,
/// Each option's id, its label and whether it is the initial one.
options: Vec<(String, Option<Text>, bool)>,
label: Text,
label_visible: bool,
},
NumberRange {
width: i32,
label: Text,
label_format: String,
start: f32,
end: f32,
initial: Option<f32>,
step: Option<f32>,
},
}
/// What an input holds while the dialog is open.
#[derive(Clone, Debug, PartialEq)]
pub enum InputValue {
Text(String),
Bool(bool),
/// The chosen option's index.
Option(usize),
/// The slider's position, 0 to 1.
Slider(f32),
}
fn text(t: &Tag) -> Text {
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))
}
fn string(t: Option<&Tag>) -> Option<String> {
match t? {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
}
}
fn number(t: Option<&Tag>) -> Option<f64> {
Some(match t? {
Tag::Byte(v) => f64::from(*v),
Tag::Short(v) => f64::from(*v),
Tag::Int(v) => f64::from(*v),
Tag::Long(v) => *v as f64,
Tag::Float(v) => f64::from(f32::from_bits(*v)),
Tag::Double(v) => f64::from_bits(*v),
_ => return None,
})
}
fn int(t: &Tag, key: &str, default: i32) -> i32 {
number(t.get(key)).map_or(default, |v| v as i32)
}
fn boolean(t: &Tag, key: &str, default: bool) -> bool {
number(t.get(key)).map_or(default, |v| v != 0.0)
}
fn list(t: Option<&Tag>) -> &[Tag] {
match t {
Some(Tag::List(items)) => items,
_ => &[],
}
}
/// A registry name without its default namespace.
fn path(id: &str) -> &str {
id.strip_prefix("minecraft:").unwrap_or(id)
}
impl Button {
/// `ActionButton.CODEC`.
fn parse(t: &Tag) -> Option<Self> {
Some(Self {
label: text(t.get("label")?),
tooltip: t.get("tooltip").map(text),
width: int(t, "width", 150),
action: t.get("action").and_then(Action::parse),
})
}
/// `NoticeDialog.DEFAULT_ACTION`.
fn ok() -> Self {
Self {
label: Text::translate("gui.ok", &[]),
tooltip: None,
width: 150,
action: None,
}
}
}
impl Click {
/// `ClickEvent.CODEC`, from its `action` (a click event in a component)
/// or `type` (an action) field.
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
let s = |key| string(t.get(key));
Some(match path(kind) {
"open_url" => Self::OpenUrl(s("url")?),
"run_command" => Self::RunCommand(s("command")?),
"suggest_command" => Self::SuggestCommand(s("command")?),
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
"change_page" => Self::ChangePage(int(t, "page", 1)),
"copy_to_clipboard" => Self::CopyToClipboard(s("value")?),
"custom" => Self::Custom {
id: s("id")?,
payload: t.get("payload").cloned(),
},
_ => return None,
})
}
}
impl Action {
/// `Action.CODEC`, dispatched on `type`.
fn parse(t: &Tag) -> Option<Self> {
let kind = string(t.get("type"))?;
Some(match path(&kind) {
"dynamic/run_command" => Self::RunCommand(string(t.get("template"))?),
"dynamic/custom" => Self::Custom {
id: string(t.get("id"))?,
additions: t.get("additions").cloned(),
},
other => Self::Static(Click::parse(t, other)?),
})
}
/// `createAction`: the click event, given each input's key, control and value.
pub fn click(&self, inputs: &[Input], values: &[InputValue]) -> Option<Click> {
let values = || inputs.iter().zip(values);
Some(match self {
Self::Static(click) => click.clone(),
Self::RunCommand(template) => Click::RunCommand(substitute(template, &|key| {
values()
.find(|(i, _)| i.key == key)
.map(|(i, v)| i.control.substitution(v))
})),
Self::Custom { id, additions } => {
let mut tag = match additions {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
for (input, value) in values() {
if let Tag::Compound(map) = &mut tag {
map.insert(input.key.as_str().into(), input.control.tag(value));
}
}
Click::Custom {
id: id.clone(),
payload: Some(tag),
}
}
})
}
}
/// `ParsedTemplate.instantiate`: each `$(key)` replaced by its value, or by
/// nothing for a key no input has.
fn substitute(template: &str, value: &dyn Fn(&str) -> Option<String>) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(at) = rest.find("$(") {
let Some(end) = rest[at..].find(')') else {
break;
};
out.push_str(&rest[..at]);
out.push_str(&value(&rest[at + 2..at + end]).unwrap_or_default());
rest = &rest[at + end + 1..];
}
out.push_str(rest);
out
}
/// `StringTag.escapeWithoutQuotes`.
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' | '\'' | '\\' => {
out.push('\\');
out.push(c);
}
'\u{8}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{c}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
c if c < ' ' => out.push_str(&format!("\\x{:02x}", c as u32)),
c => out.push(c),
}
}
out
}
impl Control {
/// `InputControl.MAP_CODEC`, dispatched on `type`.
fn parse(t: &Tag) -> Option<Self> {
let label = || t.get("label").map(text);
Some(match path(&string(t.get("type"))?) {
"text" => Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial: string(t.get("initial")).unwrap_or_default(),
max_length: int(t, "max_length", 32),
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
},
"boolean" => Self::Boolean {
label: label()?,
initial: boolean(t, "initial", false),
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
},
"single_option" => Self::SingleOption {
width: int(t, "width", 200),
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
options: list(t.get("options"))
.iter()
.filter_map(|o| match o {
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
o => Some((
string(o.get("id"))?,
o.get("display").map(text),
boolean(o, "initial", false),
)),
})
.collect(),
label: label()?,
label_visible: boolean(t, "label_visible", true),
},
"number_range" => {
let float = |key| number(t.get(key)).map(|v| v as f32);
Self::NumberRange {
width: int(t, "width", 200),
label: label()?,
label_format: string(t.get("label_format"))
.unwrap_or_else(|| "options.generic_value".into()),
start: float("start")?,
end: float("end")?,
initial: float("initial"),
step: float("step"),
}
}
_ => return None,
})
}
/// The value it starts with.
pub fn initial(&self) -> InputValue {
match self {
Self::Text { initial, .. } => InputValue::Text(initial.clone()),
Self::Boolean { initial, .. } => InputValue::Bool(*initial),
Self::SingleOption { options, .. } => {
InputValue::Option(options.iter().position(|o| o.2).unwrap_or(0))
}
Self::NumberRange {
start,
end,
initial,
..
} => InputValue::Slider(to_slider(
initial.unwrap_or((start + end) / 2.0),
*start,
*end,
)),
}
}
/// `ValueGetter.asTemplateSubstitution`.
pub fn substitution(&self, value: &InputValue) -> String {
match (self, value) {
(Self::Text { .. }, InputValue::Text(s)) => escape(s),
(
Self::Boolean {
on_true, on_false, ..
},
InputValue::Bool(b),
) => if *b { on_true } else { on_false }.clone(),
(Self::SingleOption { options, .. }, InputValue::Option(i)) => {
options.get(*i).map(|o| o.0.clone()).unwrap_or_default()
}
(Self::NumberRange { .. }, InputValue::Slider(_)) => {
number_string(self.scaled(value).unwrap_or(0.0))
}
_ => String::new(),
}
}
/// `ValueGetter.asTag`.
pub fn tag(&self, value: &InputValue) -> Tag {
match (self, value) {
(Self::Boolean { .. }, InputValue::Bool(b)) => Tag::Byte(i8::from(*b)),
(Self::NumberRange { .. }, _) => {
Tag::Float(self.scaled(value).unwrap_or(0.0).to_bits())
}
(Self::Text { .. }, InputValue::Text(s)) => Tag::String(s.as_str().into()),
_ => Tag::String(self.substitution(value).as_str().into()),
}
}
/// `RangeInfo.computeScaledValue`: a number range's value at its slider position.
pub fn scaled(&self, value: &InputValue) -> Option<f32> {
let (
Self::NumberRange {
start,
end,
initial,
step,
..
},
InputValue::Slider(slider),
) = (self, value)
else {
return None;
};
let (start, end) = (*start, *end);
let in_range = start + slider * (end - start);
let Some(step) = step else {
return Some(in_range);
};
let first = initial.unwrap_or((start + end) / 2.0);
// Math.round: half up.
let steps = ((in_range - first) / step + 0.5).floor() as i32;
let result = first + steps as f32 * step;
let position = to_slider(result, start, end);
if (0.0..=1.0).contains(&position) {
return Some(result);
}
Some(first + (steps - steps.signum()) as f32 * step)
}
}
/// `RangeInfo.scaledValueToSlider`.
fn to_slider(value: f32, start: f32, end: f32) -> f32 {
if start == end {
0.5
} else {
(value - start) / (end - start)
}
}
/// `SliderImpl.valueToString`: whole numbers without a fraction, the rest as
/// Java's `Float.toString`.
pub fn number_string(v: f32) -> String {
if v == (v as i32) as f32 {
(v as i32).to_string()
} else {
crate::snbt::java_float(v)
}
}
impl Body {
/// `DialogBody.DIALOG_BODY_CODEC`.
fn parse(t: &Tag) -> Option<Self> {
let message = |t: &Tag| -> Option<(Text, i32)> {
// PlainMessage.CODEC: the full form or just its contents.
match t {
Tag::Compound(_) if t.get("contents").is_some() => {
Some((text(t.get("contents")?), int(t, "width", 200)))
}
t => Some((text(t), 200)),
}
};
Some(match path(&string(t.get("type"))?) {
"plain_message" => {
let (contents, width) = message(t)?;
Self::Message { contents, width }
}
"item" => Self::Item {
item: t.get("item")?.clone(),
description: t.get("description").and_then(message),
show_decorations: boolean(t, "show_decorations", true),
show_tooltip: boolean(t, "show_tooltip", true),
width: int(t, "width", 16),
height: int(t, "height", 16),
},
_ => return None,
})
}
}
impl Dialog {
/// `Dialog.DIRECT_CODEC`; `None` for a type or field it can't read
/// (vanilla fails the whole packet then).
pub fn parse(t: &Tag) -> Option<Self> {
let button = |key: &str| t.get(key).and_then(Button::parse);
let kind = match path(&string(t.get("type"))?) {
"notice" => Kind::Notice(match t.get("action") {
Some(b) => Button::parse(b)?,
None => Button::ok(),
}),
"confirmation" => Kind::Confirmation {
yes: button("yes")?,
no: button("no")?,
},
"multi_action" => Kind::MultiAction {
actions: list(t.get("actions"))
.iter()
.map(Button::parse)
.collect::<Option<Vec<_>>>()
.filter(|a| !a.is_empty())?,
exit: button("exit_action"),
columns: int(t, "columns", 2),
},
"server_links" => Kind::ServerLinks {
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
},
"dialog_list" => Kind::DialogList {
dialogs: t.get("dialogs")?.clone(),
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
},
_ => return None,
};
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
let body = match t.get("body") {
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
Some(one) => Body::parse(one).into_iter().collect(),
None => Vec::new(),
};
let inputs = list(t.get("inputs"))
.iter()
.filter_map(|i| {
Some(Input {
key: string(i.get("key"))?,
control: Control::parse(i)?,
})
})
.collect();
Some(Self {
kind,
title: text(t.get("title")?),
external_title: t.get("external_title").map(text),
can_close_with_escape: boolean(t, "can_close_with_escape", true),
pause: boolean(t, "pause", true),
after_action: match string(t.get("after_action")).as_deref() {
Some("none") => AfterAction::None,
Some("wait_for_response") => AfterAction::WaitForResponse,
_ => AfterAction::Close,
},
body,
inputs,
})
}
/// `onCancel`: what Escape (or closing it) does.
pub fn on_cancel(&self) -> Option<&Action> {
match &self.kind {
Kind::Notice(b) => b.action.as_ref(),
Kind::Confirmation { no, .. } => no.action.as_ref(),
Kind::MultiAction { exit, .. }
| Kind::ServerLinks { exit, .. }
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
}
}
/// The buttons it draws, in order: the list, then the footer's.
pub fn buttons(&self) -> Vec<&Button> {
match &self.kind {
Kind::Notice(b) => vec![b],
Kind::Confirmation { yes, no } => vec![yes, no],
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
}
}
/// What `status` reports of it.
pub fn json(&self, values: &[InputValue]) -> Value {
let kind = match &self.kind {
Kind::Notice(_) => "notice",
Kind::Confirmation { .. } => "confirmation",
Kind::MultiAction { .. } => "multi_action",
Kind::ServerLinks { .. } => "server_links",
Kind::DialogList { .. } => "dialog_list",
};
let inputs: Vec<Value> = self
.inputs
.iter()
.zip(values)
.map(|(i, v)| {
let kind = match i.control {
Control::Text { .. } => "text",
Control::Boolean { .. } => "boolean",
Control::SingleOption { .. } => "single_option",
Control::NumberRange { .. } => "number_range",
};
json!({"key": i.key, "type": kind, "value": i.control.substitution(v)})
})
.collect();
json!({
"type": kind,
"title": self.title.plain(),
"body": self.body.iter().map(|b| match b {
Body::Message { contents, .. } => contents.plain(),
Body::Item { item, .. } => string(item.get("id")).unwrap_or_default(),
}).collect::<Vec<_>>(),
"inputs": inputs,
"buttons": self.buttons().iter().map(|b| b.label.plain()).collect::<Vec<_>>(),
"can_close_with_escape": self.can_close_with_escape,
"after_action": match self.after_action {
AfterAction::Close => "close",
AfterAction::None => "none",
AfterAction::WaitForResponse => "wait_for_response",
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &str) -> Tag {
Tag::String(v.into())
}
/// The register dialog AuthMe 6 sends in configuration (the shape its
/// `show_dialog` packet has on the test bed).
fn register() -> Tag {
Tag::compound([
("type", s("minecraft:multi_action")),
("title", s("Register")),
("can_close_with_escape", Tag::Byte(0)),
("after_action", s("wait_for_response")),
(
"body",
Tag::compound([
("type", s("minecraft:plain_message")),
("contents", s("Choose a password")),
]),
),
(
"inputs",
Tag::List(vec![Tag::compound([
("type", s("minecraft:text")),
("key", s("password")),
("label", s("Password")),
])]),
),
(
"actions",
Tag::List(vec![
Tag::compound([
("label", s("Register")),
(
"action",
Tag::compound([
("type", s("minecraft:dynamic/custom")),
("id", s("authme:prejoin-register/submit")),
]),
),
]),
Tag::compound([
("label", s("Cancel")),
(
"action",
Tag::compound([
("type", s("minecraft:dynamic/run_command")),
("template", s("say $(password) $(missing)!")),
]),
),
]),
]),
),
])
}
#[test]
fn decodes_a_multi_action_dialog_with_defaults() {
let d = Dialog::parse(&register()).unwrap();
assert_eq!(d.title.plain(), "Register");
assert!(!d.can_close_with_escape);
assert!(d.pause);
assert_eq!(d.after_action, AfterAction::WaitForResponse);
assert!(matches!(&d.body[..], [Body::Message { width: 200, .. }]));
let Kind::MultiAction {
actions,
exit,
columns,
} = &d.kind
else {
panic!("{:?}", d.kind)
};
assert_eq!((actions.len(), exit.is_none(), *columns), (2, true, 2));
assert_eq!(actions[0].width, 150);
assert!(d.on_cancel().is_none());
let [
Input {
key,
control:
Control::Text {
max_length: 32,
width: 200,
..
},
},
] = &d.inputs[..]
else {
panic!("{:?}", d.inputs)
};
assert_eq!(key, "password");
}
#[test]
fn actions_carry_the_input_values() {
let d = Dialog::parse(&register()).unwrap();
let values = [InputValue::Text("p\"w".into())];
let Kind::MultiAction { actions, .. } = &d.kind else {
unreachable!()
};
let custom = actions[0]
.action
.as_ref()
.unwrap()
.click(&d.inputs, &values);
assert_eq!(
custom,
Some(Click::Custom {
id: "authme:prejoin-register/submit".into(),
payload: Some(Tag::compound([("password", s("p\"w"))])),
})
);
// The template escapes the text; a key no input has becomes nothing.
let command = actions[1]
.action
.as_ref()
.unwrap()
.click(&d.inputs, &values);
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
}
#[test]
fn a_notice_without_an_action_has_ok() {
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
let Kind::Notice(b) = &d.kind else {
unreachable!()
};
assert_eq!(b.label, Text::translate("gui.ok", &[]));
// Without a title, or with an unknown type, there is no dialog.
assert!(Dialog::parse(&Tag::compound([("type", s("notice"))])).is_none());
assert!(Dialog::parse(&Tag::compound([("type", s("x")), ("title", s("Hi"))])).is_none());
}
#[test]
fn number_ranges_snap_to_their_step() {
let mut range = Control::NumberRange {
width: 200,
label: Text::literal("n"),
label_format: "options.generic_value".into(),
start: 0.0,
end: 10.0,
initial: Some(4.0),
step: Some(3.0),
};
assert_eq!(range.initial(), InputValue::Slider(0.4));
assert_eq!(range.scaled(&InputValue::Slider(0.62)), Some(7.0));
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(10.0));
let Control::NumberRange { step, .. } = &mut range else {
unreachable!()
};
*step = Some(4.0);
// 12 is out of range: one step less.
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(8.0));
assert_eq!(range.substitution(&InputValue::Slider(0.0)), "0");
assert_eq!(number_string(2.5), "2.5");
}
}
+304
View File
@@ -0,0 +1,304 @@
//! `DialogScreen` and its kinds (`SimpleDialogScreen`, `MultiButtonDialogScreen`):
//! where a server dialog's title, body and buttons go in a
//! `HeaderAndFooterLayout` (a 33 high header, the body in a `ScrollableLayout`,
//! the footer), and the `WaitingForResponseScreen` an answer can leave open.
use crate::{
dialog::{Body, Dialog, Kind},
font::Font,
text::Text,
};
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
const BAR: f32 = 33.0;
/// `DialogScreen.WARNING_BUTTON_SPRITES`: normal, disabled, highlighted.
pub const WARNING_SPRITES: [&str; 3] = [
"gui/sprites/dialog/warning_button",
"gui/sprites/dialog/warning_button_disabled",
"gui/sprites/dialog/warning_button_highlighted",
];
/// One drawn piece; `y` is from the top of the body (before it scrolls) for
/// the body's parts, from the top of the screen for the footer's.
#[derive(Clone, Debug, PartialEq)]
pub enum Part {
/// A line of text and where it starts.
Line(Text, f32, f32),
/// A button of `Dialog::buttons`.
Button { index: usize, rect: [f32; 4] },
}
#[derive(Clone, Debug)]
pub struct Layout {
/// The title and where it is drawn.
pub title: (f32, f32),
pub warning: [f32; 4],
/// The body's visible area (`ScrollableLayout`'s container): x, y, w, h.
pub area: [f32; 4],
/// The body's full height: more than the area's scrolls.
pub content: f32,
pub body: Vec<Part>,
pub footer: Vec<Part>,
}
/// A laid-out element of a `LinearLayout` before it is placed.
struct Element {
w: f32,
h: f32,
parts: Vec<Part>,
}
impl Element {
/// Its parts moved to `(x, y)`.
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
self.parts.into_iter().map(move |p| match p {
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, x, y),
},
})
}
}
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
[x + dx, y + dy, w, h]
}
/// `FrameLayout.alignInDimension` at 0.5: the offset that centres `inner`.
fn centre(outer: f32, inner: f32) -> f32 {
((outer - inner) * 0.5).trunc()
}
/// `FocusableTextWidget` with the body's settings: `width` wide, the text
/// wrapped to `width - 8` and centred, 4 of padding all round.
fn message(font: &Font, contents: &Text, width: f32) -> Element {
let lines = font.split_lines(contents, width - 8.0);
let mid = (width / 2.0).trunc();
let parts = lines
.into_iter()
.enumerate()
.map(|(i, (line, _))| {
let x = mid - (font.width_text(&line) / 2.0).trunc();
Part::Line(line, x, 4.0 + 9.0 * i as f32)
})
.collect::<Vec<_>>();
Element {
w: width,
h: 9.0 * parts.len() as f32 + 8.0,
parts,
}
}
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
/// is left in a centred row under them.
fn columns(widths: &[f32], columns: usize, first: usize) -> Element {
let columns = columns.max(1);
let full = widths.len() / columns * columns;
let row_width = |ws: &[f32]| ws.iter().sum::<f32>() + 2.0 * (ws.len().max(1) - 1) as f32;
let mut col = vec![0.0f32; columns];
for (i, w) in widths[..full].iter().enumerate() {
col[i % columns] = col[i % columns].max(*w);
}
let last = &widths[full..];
if !last.is_empty() {
// GridLayout spreads a cell over every column it spans (Divisor).
let spread = row_width(last) - 2.0 * (columns - 1) as f32;
let (each, extra) = ((spread / columns as f32).floor(), spread % columns as f32);
for (c, w) in col.iter_mut().enumerate() {
*w = w.max(each + f32::from(u8::from((c as f32) < extra)));
}
}
let x_of = |c: usize| col[..c].iter().sum::<f32>() + 2.0 * c as f32;
let mut parts = Vec::new();
for (i, w) in widths[..full].iter().enumerate() {
let (r, c) = (i / columns, i % columns);
parts.push(Part::Button {
index: first + i,
rect: [x_of(c) + centre(col[c], *w), 22.0 * r as f32, *w, 20.0],
});
}
let width = x_of(columns - 1) + col[columns - 1];
if !last.is_empty() {
let y = 22.0 * (full / columns) as f32;
let mut x = centre(width, row_width(last));
for (i, w) in last.iter().enumerate() {
parts.push(Part::Button {
index: first + full + i,
rect: [x, y, *w, 20.0],
});
x += w + 2.0;
}
}
let rows = full / columns + usize::from(!last.is_empty());
Element {
w: width,
h: (22.0 * rows as f32 - 2.0).max(0.0),
parts,
}
}
impl Layout {
/// `DialogScreen.init` and `repositionElements` on a `w` by `h` screen.
pub fn new(dialog: &Dialog, font: &Font, (w, h): (f32, f32)) -> Self {
// The header: the title, 10 right of it the warning button, both
// centred together.
let tw = font.width_text(&dialog.title);
let (hx, hy) = (centre(w, tw + 30.0), centre(BAR, 20.0));
let title = (hx, hy + centre(20.0, 9.0));
let warning = [hx + tw + 10.0, hy, 20.0, 20.0];
let mut elements: Vec<Element> = dialog
.body
.iter()
.filter_map(|b| match b {
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
Body::Item { .. } => None,
})
.collect();
let buttons = dialog.buttons();
let (listed, footer_buttons, footer_h) = match &dialog.kind {
Kind::MultiAction {
actions,
exit,
columns: n,
} => {
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
elements.push(columns(&widths, *n as usize, 0));
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
(
actions.len(),
footer,
if exit.is_some() { BAR } else { 5.0 },
)
}
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
(0, footer, if exit.is_some() { BAR } else { 5.0 })
}
// SimpleDialogScreen: the buttons side by side, 8 apart.
Kind::Notice(_) | Kind::Confirmation { .. } => {
(0, (0..buttons.len()).collect::<Vec<_>>(), BAR)
}
};
debug_assert!(listed + footer_buttons.len() <= buttons.len());
// The body: a column 10 apart, each element centred in it.
let body_w = elements.iter().map(|e| e.w).fold(0.0, f32::max);
let content = elements.iter().map(|e| e.h).sum::<f32>()
+ 10.0 * elements.len().saturating_sub(1) as f32;
// ScrollableLayout: 10 each side for the scrollbar (ReserveStrategy.BOTH),
// no higher than what the header and footer leave.
let area_w = body_w + 20.0;
let area_h = content.min(h - BAR - footer_h).max(0.0);
let area_y = (BAR + 30.0).min(h - footer_h - area_h);
let area_x = centre(w, area_w);
let mut body = Vec::new();
let mut y = 0.0;
for e in elements {
let (x, eh) = (area_x + 10.0 + centre(body_w, e.w), e.h);
body.extend(e.at(x, y));
y += eh + 10.0;
}
let widths: Vec<f32> = footer_buttons
.iter()
.map(|&i| buttons[i].width as f32)
.collect();
let row = widths.iter().sum::<f32>() + 8.0 * widths.len().saturating_sub(1) as f32;
let (mut x, fy) = (centre(w, row), h - footer_h + centre(footer_h, 20.0));
let mut footer = Vec::new();
for (&i, bw) in footer_buttons.iter().zip(&widths) {
footer.push(Part::Button {
index: i,
rect: [x, fy, *bw, 20.0],
});
x += bw + 8.0;
}
Self {
title,
warning: Self::in_bounds(warning, w, h),
area: [area_x, area_y, area_w, area_h],
content,
body,
footer,
}
}
/// `makeSureWarningButtonIsInBounds`.
fn in_bounds(r: [f32; 4], w: f32, h: f32) -> [f32; 4] {
if r[0] < 0.0 || r[1] < 0.0 || r[0] > w - 20.0 || r[1] > h - 20.0 {
[(w - 40.0).max(0.0), 5.0f32.min(h), 20.0, 20.0]
} else {
r
}
}
/// Every part where it is drawn with the body scrolled by `scroll`, and
/// whether it is in the body (clipped to its area).
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
let dy = self.area[1] - scroll;
let body = self.body.iter().map(move |p| {
let p = match p.clone() {
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, 0.0, dy),
},
};
(p, true)
});
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
}
}
/// `WaitingForResponseScreen`: its Back button's label after `ticks`, and
/// whether it shows and works (it shows after a second, works after five).
pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
let seconds = (ticks / 20).min(5);
let label = match seconds {
0 => Text::literal(""),
5 => Text::translate("gui.back", &[]),
s => Text::translate(
"gui.waitingForResponse.button.inactive",
&[Text::literal((5 - s).to_string())],
),
};
(label, seconds >= 1, seconds == 5)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grids_pack_full_rows_and_centre_the_rest() {
// AuthMe's two buttons: one row, 2 apart.
let two = columns(&[150.0, 150.0], 2, 0);
assert_eq!((two.w, two.h), (302.0, 20.0));
// Three in two columns: the third alone, centred under them.
let three = columns(&[150.0, 150.0, 150.0], 2, 0);
assert_eq!((three.w, three.h), (302.0, 42.0));
assert_eq!(
three.parts[2],
Part::Button {
index: 2,
rect: [76.0, 22.0, 150.0, 20.0]
}
);
// One in two columns spreads over both.
let one = columns(&[100.0], 2, 0);
assert_eq!(one.w, 100.0);
}
#[test]
fn the_waiting_screens_button_counts_down() {
assert!(!waiting_button(0).1);
assert_eq!(
waiting_button(20).0,
Text::translate(
"gui.waitingForResponse.button.inactive",
&[Text::literal("4")]
)
);
assert_eq!(
waiting_button(100),
(Text::translate("gui.back", &[]), true, true)
);
}
}
+23 -2
View File
@@ -37,6 +37,7 @@ mod debug_keys;
mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
mod discord;
mod dispense_fixture;
mod dispenser;
@@ -1145,6 +1146,7 @@ struct Game<'a> {
pending_pack_reload: bool,
/// A server's resource packs (game/server_packs.rs).
server_packs: server_packs::ServerPackState,
dialogs: dialogs::DialogState,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
@@ -4841,11 +4843,14 @@ impl Game<'_> {
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
// The pack prompt replaces the connecting screen (setScreen), but
// in game it opens over the world.
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
let mut batch = if (self.server_packs.prompt.is_some() || self.dialog_open())
&& self.player.is_none()
{
HudBatch::new(size)
} else {
self.hud_layers(size)
};
self.draw_dialog(&mut batch);
self.draw_pack_prompt(&mut batch);
// Gui.extractRenderState: the debug overlay comes after the screen
// and the toasts, so it is over everything, menus included.
@@ -10435,6 +10440,7 @@ impl Game<'_> {
|| (self.title.is_none()
&& self.player.is_some()
&& (self.screen.blurs_background(self.pause_menu)
|| self.dialog_open()
|| (self.screen == Screen::Win && self.win_screen_pauses())));
let radius = self.settings.menu_blur as f32;
(blurred && radius >= 1.0).then_some(radius)
@@ -11137,7 +11143,9 @@ impl ApplicationHandler for Game<'_> {
}) * wheel_sensitivity
};
let d = scaled(raw_y) as f32;
if let Some(title) = &mut self.title
if self.dialog_open() {
self.dialog_scroll(d);
} else if let Some(title) = &mut self.title
&& self.screen != Screen::Win
{
let size = self
@@ -11207,6 +11215,11 @@ impl ApplicationHandler for Game<'_> {
self.pack_prompt_click()?;
}
}
WindowEvent::MouseInput { state, button, .. } if self.dialog_open() => {
if state == ElementState::Pressed && button == MouseButton::Left {
self.dialog_click()?;
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
@@ -11421,6 +11434,13 @@ impl ApplicationHandler for Game<'_> {
self.answer_pack_prompt(false)?;
}
}
WindowEvent::KeyboardInput { event, .. } if self.dialog_open() => {
if event.state == ElementState::Pressed
&& let PhysicalKey::Code(key) = event.physical_key
{
self.dialog_key(key)?
}
}
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Chat && self.title.is_none() =>
{
@@ -11902,6 +11922,7 @@ impl<'a> Game<'a> {
world_loader: None,
pending_pack_reload: false,
server_packs: Default::default(),
dialogs: Default::default(),
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
+4
View File
@@ -390,6 +390,9 @@ impl Game<'_> {
Ok(())
}
pub(super) fn screen_label(&self) -> &'static str {
if self.dialog_open() {
return "dialog";
}
if self.title.is_some() {
return "title";
}
@@ -485,6 +488,7 @@ impl Game<'_> {
})
});
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
@@ -241,6 +241,13 @@ impl Game<'_> {
"pad" => Ok(Some(self.pad_command(&words)?)),
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
// A server's dialog takes the keys, in configuration too.
if self.dialog_open() {
if arg(2)? != "up" {
self.dialog_key(key)?;
}
return ok();
}
self.require_world()?;
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
if self.sign_edit.is_some() {
+450
View File
@@ -0,0 +1,450 @@
//! A server's dialogs bound to the game (`ClientCommonPacketListenerImpl.
//! showDialog` and `clearDialog`, `DialogScreen`, its `WarningScreen` and the
//! `WaitingForResponseScreen`): the screen over whatever is open (the
//! connecting screen in configuration), its inputs, and the actions its
//! buttons send back.
use super::Game;
use horizon_client::{
Result,
confirm_screen::Confirm,
dialog::{AfterAction, Click, Dialog, InputValue},
dialog_screen::{Layout, Part, WARNING_SPRITES, waiting_button},
hud::{HudBatch, rgba},
text::Text,
ui,
};
use horizon_world::nbt::Tag;
use std::time::Instant;
use winit::keyboard::KeyCode;
/// An open `DialogScreen`.
struct Open {
dialog: Dialog,
values: Vec<InputValue>,
scroll: ui::Scroll,
}
/// The dialog screens a server can open.
#[derive(Default)]
pub(super) struct DialogState {
open: Option<Open>,
/// `WaitingForResponseScreen`, since when.
waiting: Option<Instant>,
/// `DialogScreen.WarningScreen`, over the dialog.
warning: bool,
}
fn warning_texts() -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
(
t("menu.custom_screen_info.title"),
t("menu.custom_screen_info.contents"),
[t("menu.disconnect"), t("gui.back")],
)
}
impl Game<'_> {
/// Whether a dialog screen takes the input.
pub(super) fn dialog_open(&self) -> bool {
let d = &self.dialogs;
d.open.is_some() || d.waiting.is_some() || d.warning
}
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
/// under the warning screen, it is what Back returns to.
pub(super) fn show_dialog(&mut self, tag: &Tag) {
let Some(dialog) = Dialog::parse(tag) else {
log::warn!("Horizon: failed to show dialog for data {tag:?}");
return;
};
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
self.dialogs.open = Some(Open {
dialog,
values,
scroll: ui::Scroll::default(),
});
self.dialogs.waiting = None;
self.capture(false);
}
/// `clearDialog`: the dialog closes (under the warning screen, Back then
/// returns to what was open before it).
pub(super) fn clear_dialog(&mut self) {
if self.dialogs.open.take().is_some() && !self.dialog_open() {
self.dialog_closed();
}
}
/// Every dialog screen goes (a disconnect, or the level loading screen).
pub(super) fn close_dialogs(&mut self) {
self.dialogs = DialogState::default();
}
fn dialog_closed(&mut self) {
if self.player.is_some() && self.screen == super::Screen::Playing && self.title.is_none() {
self.capture(true);
}
}
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
AfterAction::WaitForResponse => {
self.dialogs.open = None;
self.dialogs.waiting = Some(Instant::now());
}
}
match click {
Some(Click::Custom { id, payload }) => {
if let Some(r) = &self.remote {
r.session.send_custom_click(&id, payload);
}
}
Some(Click::RunCommand(command)) => {
// In configuration there is nothing to run it (vanilla warns).
if self.player.is_some() {
let command = command.strip_prefix('/').unwrap_or(&command);
self.send_chat(&format!("/{command}"));
} else {
log::warn!(
"Horizon: commands are not supported in configuration, trying to run '{command}'"
);
}
}
Some(Click::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
Some(Click::CopyToClipboard(value)) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
None => {}
}
if !self.dialog_open() {
self.dialog_closed();
}
Ok(())
}
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
fn show_dialog_ref(&mut self, dialog: &Tag) {
match dialog {
Tag::String(id) => {
let id = id.to_string_lossy();
let name = if id.contains(':') {
id.clone()
} else {
format!("minecraft:{id}")
};
let found = self.remote.as_ref().and_then(|r| {
let entries = r.registries.registries.get("minecraft:dialog")?;
entries.iter().find(|(n, _)| *n == name)?.1.clone()
});
match found {
Some(tag) => self.show_dialog(&tag),
None => log::warn!("Horizon: unknown dialog {id}"),
}
}
tag => self.show_dialog(tag),
}
}
/// `ClientboundShowDialogPacket` in play: a registry entry or the dialog.
pub(super) fn show_play_dialog(
&mut self,
dialog: &horizon_protocol::packets::play::DialogHolder,
) {
use horizon_protocol::packets::play::DialogHolder;
match dialog {
DialogHolder::Direct(tag) => self.show_dialog(tag),
DialogHolder::Registry(id) => {
let tag = self
.remote
.as_ref()
.and_then(|r| r.registries.data("minecraft:dialog", *id).cloned());
match tag {
Some(tag) => self.show_dialog(&tag),
None => log::warn!("Horizon: unknown dialog {id}"),
}
}
}
}
fn dialog_layout(&self) -> Option<Layout> {
let open = self.dialogs.open.as_ref()?;
Some(Layout::new(
&open.dialog,
&self.gui.atlas.font,
self.gui_size(),
))
}
pub(super) fn draw_dialog(&mut self, batch: &mut HudBatch) {
if !self.dialog_open() {
return;
}
let (w, h) = (batch.width, batch.height);
let cursor = self.cursor;
let atlas = &self.gui.atlas;
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
if self.dialogs.warning {
let (title, message, [disconnect, back]) = warning_texts();
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
batch,
atlas,
cursor,
[&disconnect, &back],
);
return;
}
if let Some(since) = self.dialogs.waiting {
// HeaderAndFooterLayout(33, 0) centred: the title, the button 33 below.
let ticks = (since.elapsed().as_millis() / 50) as u32;
let (label, visible, active) = waiting_button(ticks);
let title = Text::translate("gui.waitingForResponse.title", &[]);
let top = ((h - 53.0) / 2.0).trunc();
let tw = atlas.font.width_text(&title);
batch.text_styled(
atlas,
&title,
((w - tw) / 2.0).trunc(),
top + 12.0,
u32::MAX,
true,
);
if visible {
let (bx, by) = (((w - 200.0) / 2.0).trunc(), top + 33.0);
let hover = ui::hovered(cursor, bx, by, 200.0, 20.0);
ui::button(batch, atlas, bx, by, 200.0, 20.0, &label, active, hover);
}
return;
}
let Some(layout) = self.dialog_layout() else {
return;
};
let Some(open) = self.dialogs.open.as_mut() else {
return;
};
let atlas = &self.gui.atlas;
let [ax, ay, aw, ah] = layout.area;
open.scroll.clamp(ay, ay + ah, layout.content);
batch.text_styled(
atlas,
&open.dialog.title,
layout.title.0,
layout.title.1,
u32::MAX,
true,
);
let [wx, wy, ww, wh] = layout.warning;
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
horizon_client::widgets::record("warning", layout.warning, true);
batch.sprite_scaled(
atlas,
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
wx,
wy,
ww,
wh,
u32::MAX,
);
let buttons = open.dialog.buttons();
let in_area = |y: f32| y >= ay && y < ay + ah;
let mut tooltip = None;
let mut start = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && start.is_none() {
start = Some(batch.vertices.len());
} else if !clipped && let Some(s) = start.take() {
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
}
match part {
Part::Line(text, x, y) => {
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
}
Part::Button { index, rect } => {
let [x, y, bw, bh] = rect;
let hover =
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
let b = buttons[index];
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
if hover && let Some(t) = &b.tooltip {
tooltip = Some(t.clone());
}
}
}
}
if let Some(s) = start {
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
}
if layout.content > ah {
ui::scrollbar(
batch,
atlas,
ax + aw - 6.0,
ay,
ay + ah,
layout.content,
open.scroll.amount,
);
}
if warn_hover {
tooltip = Some(Text::translate("menu.custom_screen_info.tooltip", &[]));
}
if let Some(t) = tooltip {
let lines: Vec<Text> = atlas
.font
.split_lines(&t, 170.0)
.into_iter()
.map(|l| l.0)
.collect();
ui::tooltip(batch, atlas, &lines, cursor);
}
}
/// A left click on a dialog screen.
pub(super) fn dialog_click(&mut self) -> Result<()> {
let cursor = self.cursor;
if self.dialogs.warning {
let (title, message, _) = warning_texts();
let answer = Confirm::new(&self.gui.atlas.font, self.gui_size(), &title, &message)
.answer_at(cursor);
match answer {
Some(true) => {
self.audio.play_button_click();
let reason = Text::translate("menu.custom_screen_info.disconnect", &[]);
return self.leave_server(Some(reason.plain()));
}
Some(false) => {
self.audio.play_button_click();
self.dialogs.warning = false;
if !self.dialog_open() {
self.dialog_closed();
}
}
None => {}
}
return Ok(());
}
if let Some(since) = self.dialogs.waiting {
let (w, h) = self.gui_size();
let active = waiting_button((since.elapsed().as_millis() / 50) as u32).2;
let (bx, by) = (
((w - 200.0) / 2.0).trunc(),
((h - 53.0) / 2.0).trunc() + 33.0,
);
if active && ui::hovered(cursor, bx, by, 200.0, 20.0) {
self.audio.play_button_click();
self.dialogs.waiting = None;
self.dialog_closed();
}
return Ok(());
}
let Some(layout) = self.dialog_layout() else {
return Ok(());
};
let [wx, wy, ww, wh] = layout.warning;
if ui::hovered(cursor, wx, wy, ww, wh) {
self.audio.play_button_click();
self.dialogs.warning = true;
return Ok(());
}
let Some(open) = self.dialogs.open.as_mut() else {
return Ok(());
};
let [_, ay, _, ah] = layout.area;
let mut action = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
continue;
}
match part {
Part::Button {
index,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
action = Some(index);
}
_ => {}
}
}
let Some(index) = action else {
return Ok(());
};
self.audio.play_button_click();
let d = &open.dialog;
let click = d.buttons()[index]
.action
.as_ref()
.and_then(|a| a.click(&d.inputs, &open.values));
let after = d.after_action;
self.run_dialog_action(click, after)
}
/// The mouse wheel over a dialog: its body scrolls 10 a step.
pub(super) fn dialog_scroll(&mut self, delta: f32) {
let Some(layout) = self.dialog_layout() else {
return;
};
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
}
/// A key pressed on a dialog screen: Escape closes what allows it.
pub(super) fn dialog_key(&mut self, key: KeyCode) -> Result<()> {
let escape = key == KeyCode::Escape;
if self.dialogs.warning {
if escape {
self.dialogs.warning = false;
if !self.dialog_open() {
self.dialog_closed();
}
}
return Ok(());
}
if let Some(since) = self.dialogs.waiting {
// shouldCloseOnEsc: once Back works.
if escape && waiting_button((since.elapsed().as_millis() / 50) as u32).2 {
self.dialogs.waiting = None;
self.dialog_closed();
}
return Ok(());
}
let Some(open) = self.dialogs.open.as_ref() else {
return Ok(());
};
if escape {
if !open.dialog.can_close_with_escape {
return Ok(());
}
// onClose: the cancel action, and the screen closes.
let d = &open.dialog;
let click = d.on_cancel().and_then(|a| a.click(&d.inputs, &open.values));
return self.run_dialog_action(click, AfterAction::Close);
}
Ok(())
}
/// What `state` reports: the open dialog and its values, or the waiting
/// and warning screens.
pub(super) fn dialog_status(&self) -> serde_json::Value {
let d = &self.dialogs;
let screen = if d.warning {
Some("warning")
} else if d.waiting.is_some() {
Some("waiting_for_response")
} else {
None
};
let open = d.open.as_ref().map(|open| open.dialog.json(&open.values));
serde_json::json!({ "open": open, "screen": screen })
}
}
+8
View File
@@ -489,6 +489,7 @@ impl Game<'_> {
r.session.disconnect();
}
self.server_packs_disconnect();
self.close_dialogs();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -736,6 +737,8 @@ impl Game<'_> {
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
r.profile = uuid;
@@ -764,6 +767,8 @@ impl Game<'_> {
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
@@ -2614,6 +2619,9 @@ impl Game<'_> {
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
C::SetTitlesAnimation(p) => self.titles.set_times(p.fade_in, p.stay, p.fade_out),
// ClientCommonPacketListenerImpl.handleShowDialog / handleClearDialog.
C::ShowDialog(p) => self.show_play_dialog(&p.dialog),
C::ClearDialog(_) => self.clear_dialog(),
C::ClearTitles(p) => {
self.titles.clear();
if p.value {
+2
View File
@@ -54,6 +54,8 @@ pub mod datapack;
pub mod debug_chart;
pub mod debug_keys;
pub mod debug_overlay;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod dynamic_light;
pub mod effects;
+30
View File
@@ -55,6 +55,10 @@ pub enum Event {
PackPush(cfg::ResourcePackPush),
/// ClientboundResourcePackPopPacket: one pack, or every pack.
PackPop(Option<u128>),
/// `show_dialog` in configuration: the dialog itself.
ShowDialog(horizon_world::nbt::Tag),
/// `clear_dialog` in configuration.
ClearDialog,
/// `ClientboundTransferPacket`: the server sent us to another address.
/// The connection ends right after; the game reconnects there with the
/// stored cookies.
@@ -234,6 +238,25 @@ impl Session {
w.shutdown();
}
}
/// ServerboundCustomClickActionPacket (a dialog's answer), in whichever
/// phase the connection is.
pub fn send_custom_click(&self, id: &str, payload: Option<horizon_world::nbt::Tag>) {
let click = cfg::CustomClickAction {
id: id.to_owned(),
payload,
};
let mut writer = self.writer.lock().unwrap();
let body = if self.configuring.load(Ordering::Relaxed) {
cfg::Serverbound::CustomClickAction(click).encode()
} else {
play::Serverbound::CustomClickAction(click).encode()
};
if let Some(w) = writer.as_mut()
&& w.send(&body).is_err()
{
w.shutdown();
}
}
pub fn disconnect(&mut self) {
if let Some(w) = self.writer.lock().unwrap().take() {
w.shutdown();
@@ -602,6 +625,13 @@ fn configure(
cfg::Clientbound::ResourcePackPop(p) => {
let _ = send.send(Event::PackPop(p.id));
}
// The game shows it; the answer goes back from there.
cfg::Clientbound::ShowDialog(d) => {
let _ = send.send(Event::ShowDialog(d.dialog));
}
cfg::Clientbound::ClearDialog(_) => {
let _ = send.send(Event::ClearDialog);
}
cfg::Clientbound::CookieRequest(q) => {
let payload = cookies.lock().unwrap().get(&q.key).cloned();
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
@@ -45,6 +45,32 @@ packet!(
packet!(KnownPack { namespace: String => Str, id: String => Str, version: String => Str });
packet!(SelectKnownPacks { packs: Vec<KnownPack> => List<KnownPack> });
packet!(CodeOfConduct { text: String => Str });
packet!(
/// ClientboundShowDialogPacket.CONTEXT_FREE_STREAM_CODEC: the dialog
/// itself (`Dialog.DIRECT_CODEC` as NBT).
ShowDialog { dialog: Tag => Nbt }
);
packet!(
/// ServerboundCustomClickActionPacket: a `custom` click event's id and
/// payload (a dialog's answer).
CustomClickAction { id: String => Ident, payload: Option<Tag> => ClickPayload }
);
/// ServerboundCustomClickActionPacket.UNTRUSTED_TAG_CODEC: an optional tag
/// (`End` when there is none) behind its length, 65536 bytes at most.
pub struct ClickPayload;
impl Codec<Option<Tag>> for ClickPayload {
fn read(r: &mut Reader) -> Result<Option<Tag>> {
let n = r.count(65536)?;
let tag = Reader::new(r.bytes(n)?).nbt()?;
Ok((tag != Tag::End).then_some(tag))
}
fn write(v: &Option<Tag>, w: &mut Writer) {
let mut tag = Writer::new();
tag.nbt(v.as_ref().unwrap_or(&Tag::End));
w.varint(tag.data.len() as i32);
w.bytes(&tag.data);
}
}
packet_enum!(Clientbound, crate::ids::configuration::clientbound {
CookieRequest(CookieRequest) = COOKIE_REQUEST,
@@ -63,6 +89,7 @@ packet_enum!(Clientbound, crate::ids::configuration::clientbound {
UpdateTags(UpdateTags) = UPDATE_TAGS,
SelectKnownPacks(SelectKnownPacks) = SELECT_KNOWN_PACKS,
ClearDialog(Empty) = CLEAR_DIALOG,
ShowDialog(ShowDialog) = SHOW_DIALOG,
CodeOfConduct(CodeOfConduct) = CODE_OF_CONDUCT,
});
@@ -142,6 +169,7 @@ packet_enum!(Serverbound, crate::ids::configuration::serverbound {
ResourcePack(ResourcePackResponse) = RESOURCE_PACK,
SelectKnownPacks(SelectKnownPacks) = SELECT_KNOWN_PACKS,
AcceptCodeOfConduct(Empty) = ACCEPT_CODE_OF_CONDUCT,
CustomClickAction(CustomClickAction) = CUSTOM_CLICK_ACTION,
});
#[cfg(test)]
@@ -208,6 +236,10 @@ mod tests {
Clientbound::CodeOfConduct(CodeOfConduct {
text: "Be nice".into(),
}),
Clientbound::ShowDialog(ShowDialog {
dialog: Tag::compound([("type", Tag::String("minecraft:notice".into()))]),
}),
Clientbound::ClearDialog(Empty),
] {
round_trip(p, Clientbound::encode, Clientbound::decode);
}
@@ -219,8 +251,28 @@ mod tests {
Serverbound::ResourcePack(ResourcePackResponse { id: 5, action: 1 }),
Serverbound::SelectKnownPacks(SelectKnownPacks { packs: vec![] }),
Serverbound::AcceptCodeOfConduct(Empty),
Serverbound::CustomClickAction(CustomClickAction {
id: "authme:prejoin-register/submit".into(),
payload: Some(Tag::compound([("password", Tag::String("hunter2".into()))])),
}),
Serverbound::CustomClickAction(CustomClickAction {
id: "minecraft:x".into(),
payload: None,
}),
] {
round_trip(p, Serverbound::encode, Serverbound::decode);
}
}
/// The bytes vanilla writes: the length, then the tag (a lone `End` byte
/// for none).
#[test]
fn click_payload_is_length_prefixed() {
let mut w = Writer::new();
ClickPayload::write(&None, &mut w);
assert_eq!(w.data, [1, 0]);
let mut w = Writer::new();
ClickPayload::write(&Some(Tag::Byte(1)), &mut w);
assert_eq!(w.data, [2, 1, 1]);
}
}
+60 -3
View File
@@ -13,9 +13,9 @@ use crate::{
packets::{
Empty,
configuration::{
ClientInformation, CookieRequest, CookieResponse, CustomPayload, Disconnect, KeepAlive,
Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse, StoreCookie, Transfer,
UpdateTags,
ClientInformation, CookieRequest, CookieResponse, CustomClickAction, CustomPayload,
Disconnect, KeepAlive, Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse,
StoreCookie, Transfer, UpdateTags,
},
login::Property,
},
@@ -190,6 +190,35 @@ impl Codec<i32> for HolderId {
w.varint(v + 1)
}
}
/// `Holder<Dialog>` as Dialog.STREAM_CODEC sends it: an entry of the
/// `minecraft:dialog` registry the server sent in configuration, or the
/// dialog itself.
#[derive(Clone, Debug, PartialEq)]
pub enum DialogHolder {
Registry(i32),
Direct(Tag),
}
impl Codec<DialogHolder> for DialogHolder {
fn read(r: &mut Reader) -> Result<Self> {
Ok(match r.varint()? {
0 => Self::Direct(r.nbt()?),
id => Self::Registry(id - 1),
})
}
fn write(v: &Self, w: &mut Writer) {
match v {
Self::Registry(id) => w.varint(id + 1),
Self::Direct(tag) => {
w.varint(0);
w.nbt(tag);
}
}
}
}
packet!(
/// ClientboundShowDialogPacket.
ShowDialog { dialog: DialogHolder => DialogHolder }
);
packet!(
/// ChatType.Bound.
ChatTypeBound { chat_type: i32 => HolderId, name: Tag => Nbt, target: Option<Tag> => Opt<Nbt> }
@@ -1854,6 +1883,8 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
UpdateMobEffect(UpdateMobEffect) = UPDATE_MOB_EFFECT,
UpdateTags(UpdateTags) = UPDATE_TAGS,
TrackedWaypoint(TrackedWaypoint) = WAYPOINT,
ClearDialog(Empty) = CLEAR_DIALOG,
ShowDialog(ShowDialog) = SHOW_DIALOG,
});
packet!(
/// ClientboundClearTitlesPacket: whether to also reset the timings.
@@ -2229,6 +2260,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
ContainerClose(ContainerClose) = CONTAINER_CLOSE,
CookieResponse(CookieResponse) = COOKIE_RESPONSE,
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
CustomClickAction(CustomClickAction) = CUSTOM_CLICK_ACTION,
EditBook(EditBook) = EDIT_BOOK,
Interact(Interact) = INTERACT,
KeepAlive(KeepAlive) = KEEP_ALIVE,
@@ -3024,6 +3056,7 @@ mod tests {
w.varint(101);
assert!(BookPages::read(&mut Reader::new(&w.data)).is_err());
}
#[test]
fn boss_event_round_trips() {
let add = Clientbound::BossEvent(BossEvent {
@@ -3061,4 +3094,28 @@ mod tests {
Clientbound::decode,
);
}
/// A registry entry is its id + 1, the dialog itself a 0 then its NBT.
#[test]
fn dialogs_round_trip() {
let notice = Tag::compound([("type", Tag::String("minecraft:notice".into()))]);
for dialog in [DialogHolder::Registry(3), DialogHolder::Direct(notice)] {
crate::packets::round_trip(
Clientbound::ShowDialog(ShowDialog { dialog }),
Clientbound::encode,
Clientbound::decode,
);
}
let mut w = Writer::new();
DialogHolder::write(&DialogHolder::Registry(3), &mut w);
assert_eq!(w.data, [4]);
crate::packets::round_trip(
Serverbound::CustomClickAction(CustomClickAction {
id: "minecraft:custom".into(),
payload: None,
}),
Serverbound::encode,
Serverbound::decode,
);
}
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Horizon's client on a vanilla 26.3 dedicated server, end to end.
A vanilla server (the pinned server jar, offline mode, a flat world) and one Horizon game joined to it,
driven by horizon-ctl. The server's console does what a server or its plugins do; the checks read what
the game shows and what reaches the server (its console and, for custom click actions, its debug log).
python3 tools/server_e2e.py --bin-dir target/fast # every check
python3 tools/server_e2e.py --bin-dir target/fast --check dialog
Checks:
- dialog: `/dialog show` with a multi-action dialog (a message, three buttons): the game shows it,
a command runs on the server (`trigger`), a custom action reaches it with its
payload (the server's "Received custom click action" line), `wait_for_response` leaves the
waiting screen, and `/dialog clear` closes a dialog (servers used to see nothing: the game
dropped the packets).
The game runs with fresh HORIZON_HOME, launcher and XDG folders and without audio. Screenshots and both
logs go to --out.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
import test_rust_parity as base # noqa: E402
from agent_control_e2e import Failed # noqa: E402
from lan_e2e import Game # noqa: E402
from test_dedicated_server_parity import Server # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
PLAYER = 'Steve'
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'spawn-protection': '0',
'view-distance': '3',
'simulation-distance': '3',
'pause-when-empty-seconds': '-1',
}
# MinecraftServer.handleCustomClickAction logs at debug level: the console shows that logger's debug lines.
LOG4J = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders><Console name="out" target="SYSTEM_OUT"><PatternLayout pattern="[%level] %msg%n"/></Console></Appenders>
<Loggers>
<Logger name="net.minecraft.server.MinecraftServer" level="debug"/>
<Root level="info"><AppenderRef ref="out"/></Root>
</Loggers>
</Configuration>
"""
def snbt(value):
"""A JSON value as SNBT (strings quoted, booleans as bytes)."""
if isinstance(value, bool):
return '1b' if value else '0b'
if isinstance(value, str):
return json.dumps(value)
if isinstance(value, list):
return '[' + ','.join(snbt(v) for v in value) + ']'
if isinstance(value, dict):
return '{' + ','.join(f'{k}:{snbt(v)}' for k, v in value.items()) + '}'
return str(value)
def check_dialog(server, game, out):
server.console('scoreboard objectives add answer trigger')
server.console(f'scoreboard players enable {PLAYER} answer')
dialog = {
'type': 'minecraft:multi_action',
'title': {'text': 'Sign in', 'color': 'gold'},
'body': {'type': 'minecraft:plain_message', 'contents': 'Pick a number.'},
'actions': [
{'label': 'Send', 'action': {'type': 'minecraft:run_command', 'command': 'trigger answer set 41'}},
{'label': 'Submit', 'action': {'type': 'minecraft:dynamic/custom', 'id': 'horizon:e2e',
'additions': {'tag': 'x'}}},
{'label': 'Wait', 'action': {'type': 'minecraft:dynamic/custom', 'id': 'horizon:wait'}},
],
'after_action': 'none',
'pause': False, # a pausing dialog must close after an action
}
shown = {}
server.console(f'dialog show {PLAYER} {snbt(dialog)}')
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
state = game('state')
shown['shown'] = state['dialog']['open']
assert shown['shown']['title'] == 'Sign in', shown
assert shown['shown']['buttons'] == ['Send', 'Submit', 'Wait'], shown
game.shot(out, 'dialog')
game.raw('click', 'send')
time.sleep(1)
server.console(f'scoreboard players get {PLAYER} answer')
server.wait_for(rf'{PLAYER} has 41 \[answer\]', timeout=30)
shown['trigger'] = 41
# A custom action and its additions.
game.raw('click', 'submit')
server.wait_for(r'Received custom click action horizon:e2e with payload', timeout=30)
line = next(l for l in server.lines if 'horizon:e2e' in l)
shown['custom'] = line.split('payload ', 1)[1]
assert shown['custom'] == '{tag:"x"}', shown
# after_action none: still open. Then clear it.
assert game('state')['dialog']['open'], 'the dialog closed after an action with after_action none'
server.console(f'dialog clear {PLAYER}')
game.raw('wait-for', 'screen', 'playing', '--timeout', 30)
# wait_for_response: the waiting screen until the server answers (here, never).
server.console(f'dialog show {PLAYER} {snbt(dict(dialog, after_action="wait_for_response"))}')
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
game.raw('click', 'wait')
server.wait_for(r'Received custom click action horizon:wait', timeout=30)
shown['waiting'] = game('state')['dialog']['screen']
game.shot(out, 'waiting')
assert shown['waiting'] == 'waiting_for_response', shown
# Its Back works after 5 s, and so does Escape (clear_dialog leaves it open, as in vanilla).
time.sleep(5.5)
game.raw('key', 'esc', 'tap')
game.raw('wait-for', 'screen', 'playing', '--timeout', 10)
return shown
CHECKS = {'dialog': check_dialog}
def run(bins, out, checks):
lock, jars, libraries = base.verify_artifacts()
work = Path(tempfile.mkdtemp(prefix='hz-server-e2e-'))
port = lab.free_port()
directory = work / 'server'
directory.mkdir()
props = dict(PROPERTIES, **{'server-port': str(port)})
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(directory / 'eula.txt').write_text('eula=true\n')
(work / 'log4j2.xml').write_text(LOG4J)
classpath = os.pathsep.join(map(str, [jars['server'], *libraries]))
server = Server(['java', '-Xmx2g', f'-Dlog4j.configurationFile={work / "log4j2.xml"}', '-cp', classpath,
'net.minecraft.server.Main', 'nogui'], directory, 'vanilla')
game = Game('client', bins / 'horizon-ctl', lab.RESOURCES.resolve())
for key in ('XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'HORIZON_LAUNCHER_HOME'):
game.d.env[key] = str(game.work / key.lower())
game.d.env['HORIZON_LAUNCHER_NO_SELF_UPDATE'] = '1'
results = {}
try:
server.wait_for(r'Done \(')
server.console('gamerule spawn_mobs false')
game('launch', wait=False, offscreen=sys.platform == 'darwin')
game.raw('connect', f'127.0.0.1:{port}', PLAYER)
game.raw('wait-for', 'loaded', '--timeout', 300)
server.wait_for(rf'{PLAYER} joined the game')
server.console(f'op {PLAYER}')
game.wait(20)
for name in checks:
try:
results[name] = {'pass': True, 'detail': CHECKS[name](server, game, out)}
except (Failed, AssertionError, StopIteration, KeyError) as e:
results[name] = {'pass': False, 'detail': repr(e)}
print(f'[server] {name}: {"PASS" if results[name]["pass"] else "FAIL"} {results[name]["detail"]}')
finally:
(out / 'client.log').write_text(game.close())
server.stop()
shutil.copy(server.log_path, out / 'server.log')
shutil.rmtree(game.work, ignore_errors=True)
shutil.rmtree(work, ignore_errors=True)
return results
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--check', action='append', choices=list(CHECKS), help='run only these checks (repeatable)')
parser.add_argument('--out', default=str(ROOT / '.local/server-e2e'), help='screenshots and logs go here')
args = parser.parse_args()
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'],
cwd=ROOT, check=True)
bins = ROOT / 'target/fast'
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
results = run(bins, out, args.check or list(CHECKS))
print(json.dumps(results, indent=1))
sys.exit(0 if results and all(r['pass'] for r in results.values()) else 1)
if __name__ == '__main__':
main()