From 8a050148fdeed51785340130c7cb8675a71571a4 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:29:28 -0400 Subject: [PATCH 1/8] feat(core): hide the keyboard prompt's anvil in a prompt world-age band Full-screen packs now override core/gui and pass prompt clip inputs through core/text and core/position_tex_color. While the client's world age sits in the prompt band (12000 ticks above the HUD band), they hide only what Minecraft 26.2's anvil draws: sprites inside its rect, the screen's dimming gradient, quick-craft slot highlights, fills in the rename field's band, and text in the field and label bands. Chat, titles, the action bar, and HUD panes overlapping the anvil stay. The anvil geometry and colours live in hud/renderer/prompt.rs, and the canvas contract gains prompt_world_age. --- crates/window-core/src/codegen/entries.rs | 1 + crates/window-core/src/fullscreen.rs | 8 + crates/window-core/src/hud/profile.rs | 14 +- .../window-core/src/hud/renderer/fragment.rs | 16 +- .../src/hud/renderer/fullscreen.rs | 28 +- crates/window-core/src/hud/renderer/mod.rs | 2 + crates/window-core/src/hud/renderer/prompt.rs | 313 ++++++++++++++++++ crates/window-core/src/hud/renderer/text.rs | 17 +- crates/window-core/src/hud/tests.rs | 26 +- crates/window-core/src/manifest/fullscreen.rs | 2 + crates/window-core/src/pipeline/fullscreen.rs | 1 + .../src/pipeline/tests/fullscreens.rs | 15 +- .../generated/WindowFullscreenEntries.kt | 1 + .../window/manifest/FullscreenEntries.kt | 5 + .../chunkzero/window/FullscreenManifests.kt | 1 + plugin/test/golden/fullscreen/expected.sha256 | 14 +- 16 files changed, 427 insertions(+), 37 deletions(-) create mode 100644 crates/window-core/src/hud/renderer/prompt.rs diff --git a/crates/window-core/src/codegen/entries.rs b/crates/window-core/src/codegen/entries.rs index 976c5078..285fae21 100644 --- a/crates/window-core/src/codegen/entries.rs +++ b/crates/window-core/src/codegen/entries.rs @@ -93,6 +93,7 @@ pub(super) fn fullscreen_canvas_entry_expr(canvas: &FullscreenCanvasEntry, level .arg("backdrop", kt_string(&canvas.backdrop)) .arg("overlayMarker", kt_string(&canvas.overlay_marker)) .arg("hudHiddenWorldAge", format!("{}L", canvas.hud_hidden_world_age)) + .arg("promptWorldAge", format!("{}L", canvas.prompt_world_age)) .arg("hudStripHeight", canvas.hud_strip_height) .arg("hudStripHalfWidth", canvas.hud_strip_half_width) .arg("hudMaxHealth", canvas.hud_max_health); diff --git a/crates/window-core/src/fullscreen.rs b/crates/window-core/src/fullscreen.rs index 399b5409..b57ac665 100644 --- a/crates/window-core/src/fullscreen.rs +++ b/crates/window-core/src/fullscreen.rs @@ -99,6 +99,14 @@ pub const MAX_COLORS: usize = 1 << 10; /// 24000. pub const HUD_HIDDEN_WORLD_AGE: i64 = -1_000_000_001; +/// The world age the server sends while the keyboard text prompt's anvil is open: 12000 ticks above +/// [`HUD_HIDDEN_WORLD_AGE`], so `GameTime` is about -0.17 rather than -0.67. Both bands hide the HUD, and the prompt +/// band also hides what the anvil draws. +pub const PROMPT_WORLD_AGE: i64 = HUD_HIDDEN_WORLD_AGE + 12_000; + +/// The `GameTime` between the HUD band, below it, and the prompt band, above it. +pub const PROMPT_TIME_SPLIT: f64 = -0.4; + /// The height of the HUD strip in GUI pixels, from the bottom of the screen: the hotbar, XP bar, hearts, armour, and /// hunger. pub const HUD_STRIP_HEIGHT: u32 = 56; diff --git a/crates/window-core/src/hud/profile.rs b/crates/window-core/src/hud/profile.rs index 760996bc..ab81e692 100644 --- a/crates/window-core/src/hud/profile.rs +++ b/crates/window-core/src/hud/profile.rs @@ -1,11 +1,11 @@ use std::fmt; use super::renderer::{ - render_dynamic_text_intensity_fragment, render_position_tex_color_fragment, render_position_tex_color_vertex, - render_v150_dynamic_text_background_shader, render_v150_dynamic_text_shader, render_v150_text_background_shader, - render_v150_text_intensity_fragment, render_v150_text_shader, render_v330_define_variant_text_background_shader, - render_v330_define_variant_text_fragment, render_v330_define_variant_text_shader, - render_v330_text_background_shader, render_v330_text_shader, + render_dynamic_text_intensity_fragment, render_gui_fragment, render_gui_vertex, render_position_tex_color_fragment, + render_position_tex_color_vertex, render_v150_dynamic_text_background_shader, render_v150_dynamic_text_shader, + render_v150_text_background_shader, render_v150_text_intensity_fragment, render_v150_text_shader, + render_v330_define_variant_text_background_shader, render_v330_define_variant_text_fragment, + render_v330_define_variant_text_shader, render_v330_text_background_shader, render_v330_text_shader, }; use super::rule::HudShaderRule; @@ -64,7 +64,7 @@ impl ShaderProfile { Self::Pack85To88DefineVariants => { let mut sources = vec![ ("text.vsh", render_v330_define_variant_text_shader(rules, hover, fullscreen)), - ("text.fsh", render_v330_define_variant_text_fragment(hover)), + ("text.fsh", render_v330_define_variant_text_fragment(hover, fullscreen)), ]; if !rules.is_empty() { sources.push(("text_background.vsh", render_v330_define_variant_text_background_shader(rules))); @@ -72,6 +72,8 @@ impl ShaderProfile { if fullscreen { sources.push(("position_tex_color.vsh", render_position_tex_color_vertex())); sources.push(("position_tex_color.fsh", render_position_tex_color_fragment())); + sources.push(("gui.vsh", render_gui_vertex())); + sources.push(("gui.fsh", render_gui_fragment())); } sources } diff --git a/crates/window-core/src/hud/renderer/fragment.rs b/crates/window-core/src/hud/renderer/fragment.rs index 0b353dc2..f8f67e4c 100644 --- a/crates/window-core/src/hud/renderer/fragment.rs +++ b/crates/window-core/src/hud/renderer/fragment.rs @@ -1,4 +1,5 @@ use super::hover::{HOVER_FRAGMENT_DRAW, HOVER_FRAGMENT_INPUTS}; +use super::prompt::{PROMPT_TEXT_FRAGMENT_CLIP, render_prompt_text_fragment_helpers}; const INTENSITY_SAMPLE_FUNCTION: &str = r#" vec4 window_hud_intensity_sample(sampler2D sampler, vec2 coord) { @@ -10,10 +11,15 @@ vec4 window_hud_intensity_sample(sampler2D sampler, vec2 coord) { } "#; -/// Renders `core/text.fsh` for every define variant; `IS_GRAYSCALE` departs from vanilla, and with `hover` so does -/// `IS_GUI`. -pub(in crate::hud) fn render_v330_define_variant_text_fragment(hover: bool) -> String { +/// Renders `core/text.fsh` for every define variant; `IS_GRAYSCALE` departs from vanilla, and with `hover` or +/// `fullscreen` so does `IS_GUI`. +pub(in crate::hud) fn render_v330_define_variant_text_fragment(hover: bool, fullscreen: bool) -> String { let (hover_inputs, hover_draw) = if hover { (HOVER_FRAGMENT_INPUTS, HOVER_FRAGMENT_DRAW) } else { ("", "") }; + let (prompt_inputs, prompt_clip) = if fullscreen { + (render_prompt_text_fragment_helpers(), PROMPT_TEXT_FRAGMENT_CLIP) + } else { + (String::new(), "") + }; format!( r#"#version 330 @@ -34,11 +40,11 @@ in vec4 vertexColor; in vec2 texCoord0; #ifdef IS_GRAYSCALE flat in int windowHudTextureMode; -#endif{hover_inputs} +#endif{hover_inputs}{prompt_inputs} out vec4 fragColor; -void main() {{ +void main() {{{prompt_clip} vec4 texColor = texture(Sampler0, texCoord0); #ifdef IS_GRAYSCALE if (windowHudTextureMode != 1) {{ diff --git a/crates/window-core/src/hud/renderer/fullscreen.rs b/crates/window-core/src/hud/renderer/fullscreen.rs index 62bec363..6d55c741 100644 --- a/crates/window-core/src/hud/renderer/fullscreen.rs +++ b/crates/window-core/src/hud/renderer/fullscreen.rs @@ -1,5 +1,6 @@ //! Full-screen snippets of the pack_format 85-88 `core/text` vertex shader, and the `core/position_tex_color` -//! shaders that hide the HUD and the vanilla crosshair while a full-screen window is open. +//! shaders that hide the HUD and the vanilla crosshair while a full-screen window is open, and the keyboard prompt's +//! anvil while it is open (see [`super::prompt`]). //! //! The text shader moves every vertex of a full-screen marker (see [`crate::fullscreen`]) in the world pass straight //! into clip space on the letterboxed canvas, so the projection, FOV, and view bobbing never move it. Unmarked text @@ -7,6 +8,8 @@ use crate::fullscreen::{CROSSHAIR_MARKER_ALPHA, HUD_STRIP_HALF_WIDTH, HUD_STRIP_HEIGHT, LAYER_DEPTH_STEP}; +use super::prompt::{PROMPT_SPRITE_FRAGMENT_CLIP, render_prompt_fragment_helpers, render_prompt_vertex_helpers}; + /// Imports the canvas contract into the world variant of the text vertex shader. pub(super) const FULLSCREEN_VERTEX_IMPORT: &str = " #if !defined(IS_GUI) && !defined(IS_SEE_THROUGH) @@ -92,10 +95,10 @@ pub(super) fn render_fullscreen_vertex_helpers() -> String { VERTEX_HELPERS.replace("@PLACEMENT@", CANVAS_PLACEMENT).replace("@DEPTH_STEP@", &format!("{LAYER_DEPTH_STEP:?}")) } -/// Renders `core/position_tex_color.vsh`, vanilla's GUI sprite shader plus the HUD strip inputs. Like vanilla, it -/// declares its uniform blocks inline, since the shader is also compiled before resource packs load. +/// Renders `core/position_tex_color.vsh`, vanilla's GUI sprite shader plus the HUD strip and prompt inputs. Like +/// vanilla, it declares its uniform blocks inline, since the shader is also compiled before resource packs load. pub(in crate::hud) fn render_position_tex_color_vertex() -> String { - r#"#version 330 + let source = r#"#version 330 layout(std140) uniform DynamicTransforms { mat4 ModelViewMat; @@ -124,7 +127,7 @@ out vec2 texCoord0; out vec4 vertexColor; out vec2 windowHudPos; flat out int windowHudHidden; - +@PROMPT@ void main() { gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); @@ -135,14 +138,17 @@ void main() { // orthographically, so windowHudPos is in GUI pixels: x from the center, y up from the bottom. windowHudHidden = GameTime < 0.0 && ProjMat[3][3] == 1.0 ? 1 : 0; windowHudPos = vec2(gl_Position.x / ProjMat[0][0], (gl_Position.y + 1.0) / -ProjMat[1][1]); + + window_prompt_clip(gl_Position); } -"# - .to_string() +"#; + source.replace("@PROMPT@", &render_prompt_vertex_helpers()) } /// Renders `core/position_tex_color.fsh`, which while the HUD is hidden discards GUI sprite fragments inside the HUD /// strip, and texels carrying the crosshair marker alpha, which the pack's crosshair sprite override sets on its -/// opaque texels. Clipping per fragment cuts sprites crossing the strip's edge instead of distorting them. +/// opaque texels. Clipping per fragment cuts sprites crossing the strip's edge instead of distorting them. While the +/// prompt's anvil is open, it also discards GUI sprite fragments inside the anvil's rect. pub(in crate::hud) fn render_position_tex_color_fragment() -> String { format!( r#"#version 330 @@ -164,10 +170,10 @@ in vec2 texCoord0; in vec4 vertexColor; in vec2 windowHudPos; flat in int windowHudHidden; - +{prompt_helpers} out vec4 fragColor; -void main() {{ +void main() {{{prompt_clip} if (windowHudHidden == 1 && abs(windowHudPos.x) < WINDOW_FS_HUD_STRIP.x && windowHudPos.y < WINDOW_FS_HUD_STRIP.y) {{ discard; }} @@ -185,5 +191,7 @@ void main() {{ half_width = HUD_STRIP_HALF_WIDTH, height = HUD_STRIP_HEIGHT, crosshair_alpha = CROSSHAIR_MARKER_ALPHA, + prompt_helpers = render_prompt_fragment_helpers(), + prompt_clip = PROMPT_SPRITE_FRAGMENT_CLIP, ) } diff --git a/crates/window-core/src/hud/renderer/mod.rs b/crates/window-core/src/hud/renderer/mod.rs index 8033ade2..016a5f7a 100644 --- a/crates/window-core/src/hud/renderer/mod.rs +++ b/crates/window-core/src/hud/renderer/mod.rs @@ -4,6 +4,7 @@ mod fragment; mod fullscreen; mod glsl; mod hover; +mod prompt; mod text; use crate::ir::Rgb; @@ -18,6 +19,7 @@ pub(super) use fragment::{ render_v330_define_variant_text_fragment, }; pub(super) use fullscreen::{render_position_tex_color_fragment, render_position_tex_color_vertex}; +pub(super) use prompt::{render_gui_fragment, render_gui_vertex}; pub(super) use text::{ render_v150_dynamic_text_shader, render_v150_text_shader, render_v330_define_variant_text_shader, render_v330_text_shader, diff --git a/crates/window-core/src/hud/renderer/prompt.rs b/crates/window-core/src/hud/renderer/prompt.rs new file mode 100644 index 00000000..7303f7ae --- /dev/null +++ b/crates/window-core/src/hud/renderer/prompt.rs @@ -0,0 +1,313 @@ +//! The keyboard text prompt's anvil as Minecraft 26.2 draws it, and the shader snippets that hide it while the prompt +//! is open, which the server signals by holding the world age in the prompt band (see +//! [`crate::fullscreen::PROMPT_WORLD_AGE`]). +//! +//! Only what the anvil draws is hidden, so chat, titles, the action bar, and HUD panes overlapping its rect stay: +//! +//! - GUI sprites (`core/position_tex_color`) anywhere inside the anvil's rect, since none of those overlays use +//! sprites; +//! - GUI fills (`core/gui`): the screen's dimming gradient, quick-craft highlights over the anvil's slots, and +//! anything in the rename field's band, where the caret and selection draw; +//! - GUI text (`core/text`): the rename field's band and the label bands. +//! +//! Positions are GUI pixels from the anvil's top-left. Clipping happens per fragment, so anything crossing a band's +//! edge is cut rather than distorted. + +use crate::fullscreen::PROMPT_TIME_SPLIT; + +/// The anvil screen's size; vanilla centres it with integer division. +const ANVIL_SIZE: [u32; 2] = [176, 166]; + +/// The rename field's band `[x, y, width, height]`: its text at (62, 24) with a shadow, the caret and selection one +/// pixel above and below it, and an end-of-text caret that may run past the field's 103-pixel width. +const FIELD_BAND: [u32; 4] = [62, 22, 114, 14]; + +/// The title's band at y 6 and the inventory label's at y 72, nine pixels of text and a pixel of slack each. +const LABEL_BANDS: [[u32; 4]; 2] = [[0, 6, 176, 10], [0, 72, 176, 10]]; + +/// The anvil's slot grids `[x, y, columns, rows]`: its two inputs and result, the player's main inventory, and the +/// hotbar. Slots are 16 pixels on an 18-pixel pitch. +const SLOT_GRIDS: [[u32; 4]; 5] = [[27, 47, 1, 1], [76, 47, 1, 1], [134, 47, 1, 1], [8, 84, 9, 3], [8, 142, 9, 1]]; +const SLOT_SIZE: u32 = 16; +const SLOT_PITCH: u32 = 18; + +/// The ARGB fill vanilla draws over each slot of a quick-craft drag. +const SLOT_FILL: u32 = 0x80FF_FFFF; + +/// The ARGB top and bottom of the gradient that dims the whole screen behind the anvil. +const DIM_GRADIENT: [u32; 2] = [0xC010_1010, 0xD010_1010]; + +/// The prompt's vertex outputs, written by `window_prompt_clip`. +const VERTEX_OUTPUTS: &str = " +flat out int windowPromptOpen; +out vec2 windowPromptPos;"; + +/// The fragment inputs matching [`VERTEX_OUTPUTS`]. +const FRAGMENT_INPUTS: &str = " +flat in int windowPromptOpen; +in vec2 windowPromptPos;"; + +const VERTEX_HELPERS: &str = r#" +const float WINDOW_PROMPT_TIME_SPLIT = @SPLIT@; +const vec2 WINDOW_PROMPT_ANVIL = @ANVIL@; + +// Whether the prompt's anvil is open and this vertex is drawn by an orthographic GUI pass. The HUD band lies below the +// split, so the HUD stays hidden in both bands. +bool window_prompt_open() { + return GameTime < 0.0 && GameTime > WINDOW_PROMPT_TIME_SPLIT && ProjMat[3][3] == 1.0; +} + +// The GUI projection's size, which is fractional when the GUI scale does not divide the window. +vec2 window_prompt_projected_size() { + return vec2(2.0 / ProjMat[0][0], -2.0 / ProjMat[1][1]); +} + +// The GUI size screens lay out in: the projection's size rounded up. +vec2 window_prompt_gui_size() { + return ceil(window_prompt_projected_size() - 0.01); +} + +// A GUI vertex in GUI pixels from the screen's top-left. +vec2 window_prompt_gui(vec4 clip) { + return vec2(clip.x + 1.0, 1.0 - clip.y) * 0.5 * window_prompt_projected_size(); +} + +// Sets the outputs the fragment shader clips with: whether the anvil is open, and the vertex from its top-left. +void window_prompt_clip(vec4 clip) { + windowPromptOpen = window_prompt_open() ? 1 : 0; + windowPromptPos = window_prompt_gui(clip) - floor((window_prompt_gui_size() - WINDOW_PROMPT_ANVIL) / 2.0); +} +"#; + +const FRAGMENT_HELPERS: &str = r#" +const vec2 WINDOW_PROMPT_ANVIL = @ANVIL@; +const vec4 WINDOW_PROMPT_FIELD = @FIELD@; +const vec4 WINDOW_PROMPT_LABELS[@LABEL_COUNT@] = vec4[](@LABELS@); + +// Whether the fragment lies inside rect (x, y, width, height) of the open anvil. +bool window_prompt_in(vec4 rect) { + return windowPromptOpen == 1 && all(greaterThanEqual(windowPromptPos, rect.xy)) + && all(lessThan(windowPromptPos, rect.xy + rect.zw)); +} +"#; + +/// The slot grids and their helpers, for `core/gui`. +const SLOT_HELPERS: &str = r#" +const vec4 WINDOW_PROMPT_SLOT_GRIDS[@GRID_COUNT@] = vec4[](@GRIDS@); +const float WINDOW_PROMPT_SLOT_SIZE = @SLOT_SIZE@; +const float WINDOW_PROMPT_SLOT_PITCH = @SLOT_PITCH@; +"#; + +const SLOT_CORNER_HELPERS: &str = r#" +// Whether v, from a grid's first slot along one axis, is a slot's near or far edge in a grid of count slots. +bool window_prompt_slot_edge(float v, float count) { + float m = mod(v + 0.25, WINDOW_PROMPT_SLOT_PITCH); + return v > -0.5 && v < (count - 1.0) * WINDOW_PROMPT_SLOT_PITCH + WINDOW_PROMPT_SLOT_SIZE + 0.5 + && (m < 0.5 || abs(m - WINDOW_PROMPT_SLOT_SIZE - 0.25) < 0.25); +} + +// Whether p, from the anvil's top-left, is the corner of one of its slots. +bool window_prompt_slot_corner(vec2 p) { + for (int i = 0; i < WINDOW_PROMPT_SLOT_GRIDS.length(); i++) { + vec4 grid = WINDOW_PROMPT_SLOT_GRIDS[i]; + if (window_prompt_slot_edge(p.x - grid.x, grid.z) && window_prompt_slot_edge(p.y - grid.y, grid.w)) { + return true; + } + } + return false; +} +"#; + +const SLOT_INSIDE_HELPERS: &str = r#" +// Whether the fragment lies inside one of the anvil's slots. +bool window_prompt_in_slot() { + for (int i = 0; i < WINDOW_PROMPT_SLOT_GRIDS.length(); i++) { + vec4 grid = WINDOW_PROMPT_SLOT_GRIDS[i]; + vec2 local = windowPromptPos - grid.xy; + vec2 cell = floor(local / WINDOW_PROMPT_SLOT_PITCH); + if (all(greaterThanEqual(cell, vec2(0.0))) && all(lessThan(cell, grid.zw)) + && all(lessThan(local - cell * WINDOW_PROMPT_SLOT_PITCH, vec2(WINDOW_PROMPT_SLOT_SIZE)))) { + return true; + } + } + return false; +} +"#; + +fn vec2(value: [u32; 2]) -> String { + format!("vec2({}.0, {}.0)", value[0], value[1]) +} + +fn vec4(value: [u32; 4]) -> String { + format!("vec4({}.0, {}.0, {}.0, {}.0)", value[0], value[1], value[2], value[3]) +} + +fn vec4_list(values: &[[u32; 4]]) -> String { + values.iter().map(|value| vec4(*value)).collect::>().join(", ") +} + +/// An ARGB color as the `ivec4` of a vertex color's RGBA bytes. +fn rgba(argb: u32) -> String { + let [a, r, g, b] = argb.to_be_bytes(); + format!("ivec4({r}, {g}, {b}, {a})") +} + +/// The prompt's vertex outputs and helpers. Needs `ProjMat` and `GameTime`. +pub(super) fn render_prompt_vertex_helpers() -> String { + VERTEX_OUTPUTS.to_string() + + &VERTEX_HELPERS.replace("@SPLIT@", &format!("{PROMPT_TIME_SPLIT:?}")).replace("@ANVIL@", &vec2(ANVIL_SIZE)) +} + +/// The prompt's fragment inputs and helpers. +pub(super) fn render_prompt_fragment_helpers() -> String { + FRAGMENT_INPUTS.to_string() + + &FRAGMENT_HELPERS + .replace("@ANVIL@", &vec2(ANVIL_SIZE)) + .replace("@FIELD@", &vec4(FIELD_BAND)) + .replace("@LABEL_COUNT@", &LABEL_BANDS.len().to_string()) + .replace("@LABELS@", &vec4_list(&LABEL_BANDS)) +} + +fn slot_helpers() -> String { + SLOT_HELPERS + .replace("@GRID_COUNT@", &SLOT_GRIDS.len().to_string()) + .replace("@GRIDS@", &vec4_list(&SLOT_GRIDS)) + .replace("@SLOT_SIZE@", &format!("{SLOT_SIZE}.0")) + .replace("@SLOT_PITCH@", &format!("{SLOT_PITCH}.0")) +} + +/// The GUI variant's prompt outputs and helpers for `core/text.vsh`. +pub(super) fn render_prompt_text_vertex_helpers() -> String { + format!("\n#ifdef IS_GUI{}#endif", render_prompt_vertex_helpers()) +} + +/// Runs last in the text vertex `main`, after any hover glyph has moved. +pub(super) const PROMPT_TEXT_VERTEX_APPLY: &str = " +#ifdef IS_GUI + window_prompt_clip(gl_Position); +#endif"; + +/// The GUI variant's prompt inputs and helpers for `core/text.fsh`. +pub(super) fn render_prompt_text_fragment_helpers() -> String { + format!("\n#ifdef IS_GUI{}#endif", render_prompt_fragment_helpers()) +} + +/// Runs first in the text fragment `main`: hides GUI text in the field and label bands. +pub(super) const PROMPT_TEXT_FRAGMENT_CLIP: &str = " +#ifdef IS_GUI + if (window_prompt_in(WINDOW_PROMPT_FIELD) || window_prompt_in(WINDOW_PROMPT_LABELS[0]) + || window_prompt_in(WINDOW_PROMPT_LABELS[1])) { + discard; + } +#endif"; + +/// Runs first in the sprite fragment `main`: hides GUI sprites anywhere in the anvil's rect. +pub(super) const PROMPT_SPRITE_FRAGMENT_CLIP: &str = " + if (window_prompt_in(vec4(vec2(0.0), WINDOW_PROMPT_ANVIL))) { + discard; + }"; + +/// Renders `core/gui.vsh`, vanilla's GUI fill shader plus the prompt's outputs, and a flag marking the vertices of the +/// anvil's own fills: 1 for the dimming gradient, at the screen's corners, and 2 for a quick-craft slot highlight, at +/// a slot's corners. Like vanilla, it declares its uniform blocks inline. +pub(in crate::hud) fn render_gui_vertex() -> String { + format!( + r#"#version 330 + +layout(std140) uniform DynamicTransforms {{ + mat4 ModelViewMat; + vec4 ColorModulator; + vec3 ModelOffset; + mat4 TextureMat; +}}; +layout(std140) uniform Projection {{ + mat4 ProjMat; +}}; +layout(std140) uniform Globals {{ + ivec3 CameraBlockPos; + vec3 CameraOffset; + vec2 ScreenSize; + float GlintAlpha; + float GameTime; + int MenuBlurRadius; + int UseRgss; +}}; + +in vec3 Position; +in vec4 Color; + +out vec4 vertexColor; +flat out int windowPromptFill; +{helpers}{slots}{corners} +// Whether v lies within a quarter pixel of a or b. +bool window_prompt_near(float v, float a, float b) {{ + return abs(v - a) < 0.25 || abs(v - b) < 0.25; +}} + +int window_prompt_fill(vec4 clip, vec4 color) {{ + if (windowPromptOpen == 0) {{ + return 0; + }} + ivec4 rgba = ivec4(round(color * 255.0)); + vec2 gui = window_prompt_gui(clip); + vec2 size = window_prompt_gui_size(); + if ((rgba == {dim_top} || rgba == {dim_bottom}) && window_prompt_near(gui.x, 0.0, size.x) + && window_prompt_near(gui.y, 0.0, size.y)) {{ + return 1; + }} + return rgba == {slot_fill} && window_prompt_slot_corner(windowPromptPos) ? 2 : 0; +}} + +void main() {{ + gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); + + vertexColor = Color; + + window_prompt_clip(gl_Position); + windowPromptFill = window_prompt_fill(gl_Position, Color); +}} +"#, + helpers = render_prompt_vertex_helpers(), + slots = slot_helpers(), + corners = SLOT_CORNER_HELPERS, + dim_top = rgba(DIM_GRADIENT[0]), + dim_bottom = rgba(DIM_GRADIENT[1]), + slot_fill = rgba(SLOT_FILL), + ) +} + +/// Renders `core/gui.fsh`, vanilla's GUI fill shader, which while the prompt's anvil is open discards the dimming +/// gradient, quick-craft highlights inside the anvil's slots, and anything in the rename field's band. +pub(in crate::hud) fn render_gui_fragment() -> String { + format!( + r#"#version 330 + +layout(std140) uniform DynamicTransforms {{ + mat4 ModelViewMat; + vec4 ColorModulator; + vec3 ModelOffset; + mat4 TextureMat; +}}; + +in vec4 vertexColor; +flat in int windowPromptFill; +{helpers}{slots}{inside} +out vec4 fragColor; + +void main() {{ + if (windowPromptFill == 1 || (windowPromptFill == 2 && window_prompt_in_slot()) + || window_prompt_in(WINDOW_PROMPT_FIELD)) {{ + discard; + }} + vec4 color = vertexColor; + if (color.a == 0.0) {{ + discard; + }} + fragColor = color * ColorModulator; +}} +"#, + helpers = render_prompt_fragment_helpers(), + slots = slot_helpers(), + inside = SLOT_INSIDE_HELPERS, + ) +} diff --git a/crates/window-core/src/hud/renderer/text.rs b/crates/window-core/src/hud/renderer/text.rs index b36f1013..5af74519 100644 --- a/crates/window-core/src/hud/renderer/text.rs +++ b/crates/window-core/src/hud/renderer/text.rs @@ -7,6 +7,7 @@ use super::glsl::{ }; use super::hover::{HOVER_VERTEX_APPLY, render_hover_vertex_helpers}; use super::marker_id; +use super::prompt::{PROMPT_TEXT_VERTEX_APPLY, render_prompt_text_vertex_helpers}; // Minecraft's actionbar renderer (Gui.extractOverlayMessage in 26.1.2, // Hud.extractOverlayMessage in 26.2) translates to guiHeight - 68 and draws @@ -289,7 +290,8 @@ void main() {{ /// Renders `core/text.vsh`, which vanilla compiles once per pipeline with `IS_GUI`, /// `IS_SEE_THROUGH`, and `IS_GRAYSCALE` defines; each variant keeps vanilla's inputs and bindings. With `fullscreen`, -/// the world variant places full-screen markers on the canvas before anything else runs. +/// the world variant places full-screen markers on the canvas before anything else runs, and the GUI variant passes +/// the keyboard prompt's clip inputs on. pub(in crate::hud) fn render_v330_define_variant_text_shader( rules: &[HudShaderRule], hover: bool, @@ -302,10 +304,15 @@ pub(in crate::hud) fn render_v330_define_variant_text_shader( }; let (hover_helpers, hover_apply) = if hover { (render_hover_vertex_helpers(), HOVER_VERTEX_APPLY) } else { (String::new(), "") }; - let (fullscreen_import, fullscreen_helpers, fullscreen_apply) = if fullscreen { - (FULLSCREEN_VERTEX_IMPORT, render_fullscreen_vertex_helpers(), FULLSCREEN_VERTEX_APPLY) + let (fullscreen_import, fullscreen_helpers, fullscreen_apply, prompt_apply) = if fullscreen { + ( + FULLSCREEN_VERTEX_IMPORT, + render_fullscreen_vertex_helpers() + &render_prompt_text_vertex_helpers(), + FULLSCREEN_VERTEX_APPLY, + PROMPT_TEXT_VERTEX_APPLY, + ) } else { - ("", String::new(), "") + ("", String::new(), "", "") }; format!( @@ -353,7 +360,7 @@ void main() {{{fullscreen_apply} texCoord0 = UV0; #ifdef IS_GRAYSCALE windowHudTextureMode = window_hud_texture_mode(Color); -#endif{hover_apply} +#endif{hover_apply}{prompt_apply} }} "# ) diff --git a/crates/window-core/src/hud/tests.rs b/crates/window-core/src/hud/tests.rs index 2b7abd08..d36cc46b 100644 --- a/crates/window-core/src/hud/tests.rs +++ b/crates/window-core/src/hud/tests.rs @@ -124,7 +124,15 @@ fn fullscreen_branch_joins_the_text_shader_and_hides_the_hud_strip() { let all = emit(Some(88), &[&hud], &[0], true, true).unwrap(); assert_eq!( names(&all), - ["text.vsh", "text.fsh", "text_background.vsh", "position_tex_color.vsh", "position_tex_color.fsh"] + [ + "text.vsh", + "text.fsh", + "text_background.vsh", + "position_tex_color.vsh", + "position_tex_color.fsh", + "gui.vsh", + "gui.fsh" + ] ); let vertex = &all.files[0].contents; assert!(vertex.contains("#moj_import \n#if !defined(IS_GUI) && !defined(IS_SEE_THROUGH)\n#moj_import \n#endif")); @@ -133,16 +141,28 @@ fn fullscreen_branch_joins_the_text_shader_and_hides_the_hud_strip() { assert!(main.starts_with("void main() {\n#if !defined(IS_GUI) && !defined(IS_SEE_THROUGH)\n if (window_fs_is_marker(Color)) {\n window_fs_apply();\n return;\n }\n#endif\n vec3 pos = Position;\n window_hud_apply(pos, Color);"), "{main}"); assert!(main.contains("window_hover_apply();")); let without = emit(Some(88), &[&hud], &[0], true, false).unwrap(); - assert_eq!(all.files[1], without.files[1]); assert_eq!(all.files[2], without.files[2]); let alone = emit(Some(88), &[], &[], false, true).unwrap(); - assert_eq!(names(&alone), ["text.vsh", "text.fsh", "position_tex_color.vsh", "position_tex_color.fsh"]); + assert_eq!( + names(&alone), + ["text.vsh", "text.fsh", "position_tex_color.vsh", "position_tex_color.fsh", "gui.vsh", "gui.fsh"] + ); assert!(alone.files[0].contents.contains("void window_fs_apply()")); let fragment = &alone.files[3].contents; assert!(fragment.contains("const vec2 WINDOW_FS_HUD_STRIP = vec2(96.0, 56.0);"), "{fragment}"); assert!(alone.files[2].contents.contains("float GameTime;\n int MenuBlurRadius;\n int UseRgss;\n};")); + // While the prompt's anvil is open, GUI text is hidden only in its field and label bands, and fills only in the + // field band or when they are the dimming gradient or a slot highlight. + let text = &alone.files[1].contents; + assert!(text.contains("const vec4 WINDOW_PROMPT_FIELD = vec4(62.0, 22.0, 114.0, 14.0);"), "{text}"); + assert!(text.contains("void main() {\n#ifdef IS_GUI\n if (window_prompt_in(WINDOW_PROMPT_FIELD)"), "{text}"); + assert!(alone.files[0].contents.contains("const float WINDOW_PROMPT_TIME_SPLIT = -0.4;")); + let gui = &alone.files[4].contents; + assert!(gui.contains("rgba == ivec4(16, 16, 16, 192) || rgba == ivec4(16, 16, 16, 208)"), "{gui}"); + assert!(gui.contains("rgba == ivec4(255, 255, 255, 128) && window_prompt_slot_corner(windowPromptPos)"), "{gui}"); + for pack_format in [None, Some(84), Some(89)] { let err = emit(pack_format, &[], &[], false, true).unwrap_err().to_string(); assert!(err.contains("full-screen windows"), "{err}"); diff --git a/crates/window-core/src/manifest/fullscreen.rs b/crates/window-core/src/manifest/fullscreen.rs index a0cfd495..c6e8560e 100644 --- a/crates/window-core/src/manifest/fullscreen.rs +++ b/crates/window-core/src/manifest/fullscreen.rs @@ -130,6 +130,8 @@ pub struct FullscreenCanvasEntry { pub overlay_marker: String, /// The world age the server sends while the HUD is hidden. pub hud_hidden_world_age: i64, + /// The world age the server sends while the keyboard text prompt's anvil is open, which also hides the anvil. + pub prompt_world_age: i64, /// The HUD strip's height in GUI pixels, from the bottom of the screen. pub hud_strip_height: u32, /// Half the HUD strip's width in GUI pixels, either side of the screen's center. diff --git a/crates/window-core/src/pipeline/fullscreen.rs b/crates/window-core/src/pipeline/fullscreen.rs index 24a19e74..60456627 100644 --- a/crates/window-core/src/pipeline/fullscreen.rs +++ b/crates/window-core/src/pipeline/fullscreen.rs @@ -239,6 +239,7 @@ pub(super) fn emit_contract(ctx: &mut CompileContext<'_>, mut contract: Contract backdrop, overlay_marker, hud_hidden_world_age: fullscreen::HUD_HIDDEN_WORLD_AGE, + prompt_world_age: fullscreen::PROMPT_WORLD_AGE, hud_strip_height: fullscreen::HUD_STRIP_HEIGHT, hud_strip_half_width: fullscreen::HUD_STRIP_HALF_WIDTH, hud_max_health: fullscreen::HUD_MAX_HEALTH, diff --git a/crates/window-core/src/pipeline/tests/fullscreens.rs b/crates/window-core/src/pipeline/tests/fullscreens.rs index d34184bb..15f05144 100644 --- a/crates/window-core/src/pipeline/tests/fullscreens.rs +++ b/crates/window-core/src/pipeline/tests/fullscreens.rs @@ -108,7 +108,10 @@ fn full_screen_windows_emit_their_shaders_and_need_the_define_variant_profile() let out = compile(menu.clone()).unwrap(); let shaders: Vec<&str> = out.files.iter().filter_map(|file| file.path.strip_prefix("assets/minecraft/shaders/core/")).collect(); - assert_eq!(shaders, ["position_tex_color.fsh", "position_tex_color.vsh", "text.fsh", "text.vsh"]); + assert_eq!( + shaders, + ["gui.fsh", "gui.vsh", "position_tex_color.fsh", "position_tex_color.vsh", "text.fsh", "text.vsh"] + ); let crosshair = out.files.iter().find(|file| file.path == fullscreen::CROSSHAIR_SPRITE_PATH).unwrap(); let crosshair = Texture::decode_png(crosshair.contents.as_bytes()).unwrap(); @@ -328,7 +331,15 @@ fn horse_mode_emits_the_cursor_armour_and_entity_shader() { let out = compile(json!({ "name": "lookout", "mode": "horse", "children": [] })).unwrap(); assert_eq!( shaders(&out), - ["entity.vsh", "position_tex_color.fsh", "position_tex_color.vsh", "text.fsh", "text.vsh"] + [ + "entity.vsh", + "gui.fsh", + "gui.vsh", + "position_tex_color.fsh", + "position_tex_color.vsh", + "text.fsh", + "text.vsh" + ] ); let horse = out.manifest.fullscreen_canvas.as_ref().unwrap().horse.as_ref().expect("the horse entry is present"); assert_eq!(horse.offset, fullscreen::horse::OFFSET); diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt index c9516ddb..b92dc83f 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt @@ -706,6 +706,7 @@ internal object WindowFullscreenEntries { backdrop = "\uE6AF\uDB80\uDC07\uDB80\uDC0A", overlayMarker = "#a3f800", hudHiddenWorldAge = -1000000001L, + promptWorldAge = -999988001L, hudStripHeight = 56, hudStripHalfWidth = 96, hudMaxHealth = 20, diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt index 589934ac..cb616f02 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt @@ -136,6 +136,11 @@ public data class FullscreenCanvasEntry( @SerialName("overlay_marker") val overlayMarker: String, /** The world age the server sends while the HUD is hidden, which makes the shaders' `GameTime` negative. */ @SerialName("hud_hidden_world_age") val hudHiddenWorldAge: Long, + /** + * The world age the server sends while the keyboard text prompt's anvil is open: a band of negative `GameTime` that + * hides the HUD as [hudHiddenWorldAge] does and also what the anvil draws. + */ + @SerialName("prompt_world_age") val promptWorldAge: Long, /** The HUD strip's height in GUI pixels, from the bottom of the screen. */ @SerialName("hud_strip_height") val hudStripHeight: Int, /** Half the HUD strip's width in GUI pixels, either side of the screen's center. */ diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt index 814b81d3..179ede8b 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt @@ -40,6 +40,7 @@ object FullscreenManifests { backdrop = "BACKDROP", overlayMarker = OVERLAY_MARKER, hudHiddenWorldAge = -1_000_000_001, + promptWorldAge = -999_988_001, hudStripHeight = 56, hudStripHalfWidth = 96, hudMaxHealth = 20, diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 26cdc03b..080065f5 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -1,8 +1,10 @@ b902022fd657ad3b7b7cd44391210c7c6f2d5086bebba022fecbbee2a951df3f dist/assets/minecraft/shaders/core/entity.vsh -0ea5dc4a51200a23e36d302db7208f23670ce635ab6a47b8717af27717d16694 dist/assets/minecraft/shaders/core/position_tex_color.fsh -2ba7e616fbdb0334aab7a729aabc0531f4d9daf73ad24aa249971dc6ca2972d8 dist/assets/minecraft/shaders/core/position_tex_color.vsh -950440cf29257b3e036e9aeb2aa7e8e8383e0855820f46b8cda64f77bdb029b6 dist/assets/minecraft/shaders/core/text.fsh -10955aaeef507e673f474143791a4a31ed838d84e6d3ed54231e37f02e425fb6 dist/assets/minecraft/shaders/core/text.vsh +69395c6f856c85ba732d28dff82d67095fbe7a73ae8da9e979a938bc5da1bd63 dist/assets/minecraft/shaders/core/gui.fsh +de99b339c5bc4b28a85d9fe06066c528fa7c10497037c51dca18652360f14aa3 dist/assets/minecraft/shaders/core/gui.vsh +36ccd0bdb0fd5962f45383d9c95192777575ae53cb48762ed62c0585d70f2d62 dist/assets/minecraft/shaders/core/position_tex_color.fsh +df4975456519e08d7a7065027ae566a58314fa95d6a7f58764a2bac5638e1e72 dist/assets/minecraft/shaders/core/position_tex_color.vsh +edf376dba2d545c347720fd783e829f024c9381485d14cfbf1bd66d40877b7e3 dist/assets/minecraft/shaders/core/text.fsh +f6a980f98a5850b6530119b72ef064d6e3365f2176c2ba9c50b7e3ce6524173b dist/assets/minecraft/shaders/core/text.vsh d8c8dc63512f18a6f85b86ce57d473cc2b2b76453ba1c0fd3ba97b47bbb5b26f dist/assets/minecraft/shaders/include/window_fullscreen.glsl 8f0a28c175d2c93ff86aa1377f34c9e8fbc76e922ca34ae2f6db19dd557d285e dist/assets/minecraft/textures/gui/sprites/hud/crosshair.png 69beab9c90d9a84fa45fd48c393db16ea354338990a7955935cf8518f6fbcf74 dist/assets/window/equipment/fullscreen/horse_cursor.json @@ -33,7 +35,7 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -8a52ccfe685388df207fce1a7ebb4fb8e6256d14a88cb39e3db2abcb095e281f dist/assets/window/window/debug.json +ead1d487194685043ec9852b21041eeced533874dd0591e4960505aca6491b87 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 kotlin-out/MenuFullscreen.kt @@ -41,7 +43,7 @@ f30b961fdca503fa68f87b140e31c8a42b48ad830a84c029fc7a7435760bfff9 kotlin-out/Win 2c40c9884ffe5ae1a29707d1195e2a15ad469816e166981e0be0b73b1e1a3696 kotlin-out/WindowEntries.kt 803c1f2387cf2cca16681dad3c4b38b360eefcda17be343491aca1ec552cc0ce kotlin-out/WindowFonts.kt bd87985f4223a45529a54d7453105bf2525188d337c8910cd4935344e0cec3bd kotlin-out/WindowFullscreenDefinitions.kt -7d5e947bf50d0a80f24891d586a3c02648844aa5a4b447c3cfea75f84c481e20 kotlin-out/WindowFullscreenEntries.kt +07639a3683a2cb74299b1416ea3748055549caa445a58e572390498ffd26a1f3 kotlin-out/WindowFullscreenEntries.kt 7e12c0496b9c2f1e69bebd517cf396d3d178322561535b9b4521b9e5e2f4c644 kotlin-out/WindowHudDefinitions.kt f14312feddb64e8e65d2916d8e066c8323d35628c9da4e722e2f12f057d516e3 kotlin-out/WindowHudEntries.kt 9a1b53ef8c7c748afb02db43707298badd154a58dfc0ba7abada76b7a10d212f kotlin-out/WindowPackData.kt From 677e32e152707e742f4e899463cca945f17008c4 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:29:28 -0400 Subject: [PATCH 2/8] feat(runtime): experimental anvil keyboard text prompt for full-screen windows FullscreenView.promptText(initial, onEdit, onDone) is now the keyboard: an untitled anvil the pack hides, whose renames the view draws on its canvas, re-rendering at once. Escape or any click finishes with the text; there is no cancel. It is marked @ExperimentalFullscreenApi. The native dialog stays as promptTextDialog(title, initial, onDone). Both kinds share one pending prompt in MinestomTextPrompt: opening one replaces the other, clearing the dialog or closing the anvil and restoring the band, slots, and XP first. A container takeover or the window ending resolves null, as before. The hovered tooltip is hidden while any prompt is open. --- .../internal/MinestomAnvilKeyboard.kt | 258 ++++++++++++++++++ .../minestom/internal/MinestomFullscreen.kt | 90 ++++-- .../minestom/internal/MinestomTextPrompt.kt | 134 +++++++-- .../window/minestom/MinestomFullscreenTest.kt | 236 +++++++++++++++- .../window/ExperimentalFullscreenApi.kt | 13 + .../com/chunkzero/window/FullscreenSession.kt | 39 ++- .../com/chunkzero/window/FullscreenView.kt | 63 ++++- .../chunkzero/window/host/FullscreenHost.kt | 34 ++- .../chunkzero/window/internal/Reactivity.kt | 8 + .../chunkzero/window/FullscreenSessionTest.kt | 53 +++- 10 files changed, 849 insertions(+), 79 deletions(-) create mode 100644 jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt create mode 100644 jvm/runtime/src/main/kotlin/com/chunkzero/window/ExperimentalFullscreenApi.kt diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt new file mode 100644 index 00000000..e6b78e69 --- /dev/null +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt @@ -0,0 +1,258 @@ +package com.chunkzero.window.minestom.internal + +import net.kyori.adventure.text.Component +import net.minestom.server.component.DataComponents +import net.minestom.server.entity.Player +import net.minestom.server.event.player.PlayerPacketEvent +import net.minestom.server.inventory.type.AnvilInventory +import net.minestom.server.item.ItemStack +import net.minestom.server.network.packet.client.common.ClientPongPacket +import net.minestom.server.network.packet.client.play.ClientClickWindowPacket +import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket +import net.minestom.server.network.packet.client.play.ClientNameItemPacket +import net.minestom.server.network.packet.server.common.PingPacket +import net.minestom.server.network.packet.server.play.BundlePacket +import net.minestom.server.network.packet.server.play.SetExperiencePacket +import net.minestom.server.network.packet.server.play.WindowItemsPacket +import net.minestom.server.network.packet.server.play.WindowPropertyPacket +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicInteger + +/** + * The keyboard text prompt's anvil: untitled, and used only to capture typing. While it is open, [sendBand] sends the + * prompt's world age band, which the pack's GUI shaders read as "hide what the anvil draws", and every slot it shows + * holds [blank], which draws nothing, shows no tooltip, and, in the hotbar, hides the arm. Closing it puts back the band, the player's + * slots through [restoreSlots], and their XP. + * + * The input paper is named after the field's text, which vanilla copies into the rename field, and has no + * enchantments, so the client computes no repair cost. The client resets its field only when the input slot changes, + * so each seed carries a fresh lore line. A seeding send (an open, or a continuation once the field fills) is bundled + * with a ping; rename packets before its pong come from an earlier anvil or are the seed's echo, and are dropped. The + * field holds [MAX_LENGTH] characters, so a full field moves its text into [kept] and starts empty again. + * + * The server cannot see the client's caret, so a click anywhere in the anvil's window, or Escape, which closes it, + * finishes the edit with the text so far. Another inventory replacing the anvil displaces it. Each is reported to + * [events] after the anvil's state is reset and the player's band, slots, and XP are put back. + */ +internal class MinestomAnvilKeyboard( + private val player: Player, + private val blank: ItemStack, + private val sendBand: () -> Unit, + private val restoreSlots: () -> Unit, + private val events: Events, +) { + /** What the player did with the open anvil. */ + interface Events { + /** The field changed; [text] includes the kept pieces. */ + fun onEdit(text: String) + + /** The player clicked or pressed Escape, which closed the anvil. */ + fun onFinish(text: String) + + /** Another inventory replaced the anvil, or the server closed it. */ + fun onDisplaced() + } + + private val unenchanted = blank.without(DataComponents.ENCHANTMENTS) + private var anvil: PromptAnvil? = null + + /** The text of earlier fields that filled up. */ + private var kept = "" + + /** The rename field's text as of the latest edit, or the text it was seeded with. */ + private var field = "" + + /** Distinguishes each seed, so a reseed always changes the input slot and resets the client's field. */ + private var seeds = 0 + + /** The ping that settles the latest seeding send, until its pong arrives. */ + private var awaiting: Int? = null + + /** The window id of the open anvil, or [NO_WINDOW]; read by the connection's writer thread. */ + @Volatile + private var window = NO_WINDOW + + /** The window ids of anvils whose open packet the writer has not written yet, oldest first. */ + private val unwritten = ConcurrentLinkedQueue() + + /** Whether the anvil is open, so the world age belongs in the prompt band. */ + val isOpen: Boolean get() = window != NO_WINDOW + + /** + * Whether the open packet the writer is writing for [windowId] is an anvil's rather than a server container's. Call + * once per open packet, on the writer thread. + */ + fun ownsOpen(windowId: Int): Boolean = unwritten.peek() == windowId && unwritten.poll() != null + + /** Opens the anvil holding [initial], or reseeds the open one. False when an inventory listener refused it. */ + fun open(initial: String): Boolean { + val reused = anvil?.takeIf { player.openInventory === it } + val target = reused ?: PromptAnvil() + kept = initial.take(initial.length - initial.length % MAX_LENGTH) + field = initial.drop(kept.length) + target.setItemStack(INPUT_SLOT, seed(field), false) + anvil = target + window = target.windowId.toInt() + if (reused == null) unwritten += window + val opened = + synced(seeding = true) { + sendBand() + val shown = reused != null || (player.openInventory(target) && player.openInventory === target) + if (shown) sendSlots(target) + shown + } + if (!opened) { + unwritten.remove(target.windowId.toInt()) + reset() + restore() + } + return opened + } + + /** Closes the anvil, if one is open, without reporting it. */ + fun close() { + val current = anvil ?: return + reset() + synced(seeding = false) { + if (player.openInventory === current) player.closeInventory() + restore() + } + } + + fun onPacket(event: PlayerPacketEvent) { + val current = anvil ?: return + when (val packet = event.packet) { + is ClientPongPacket -> { + if (packet.id() == awaiting) awaiting = null + } + + is ClientNameItemPacket -> { + event.isCancelled = true + if (awaiting == null) edit(current, packet.itemName()) + } + + is ClientClickWindowPacket -> { + if (packet.windowId() != window) return + event.isCancelled = true + finish(current, closing = true) + } + + // Minestom closes the anvil once this event returns, and ignores the close if another window opened since. + is ClientCloseWindowPacket -> { + if (packet.windowId() == window) finish(current, closing = false) + } + + else -> { + Unit + } + } + } + + private fun edit( + current: PromptAnvil, + name: String, + ) { + val typed = name.filter { it >= ' ' && it != '\u007f' && it != '§' } + if (typed.length >= MAX_LENGTH) { + kept += typed.take(MAX_LENGTH) + field = "" + current.setItemStack(INPUT_SLOT, seed(field), false) + synced(seeding = true) { sendSlots(current) } + } else if (typed != field) { + field = typed + } else { + return + } + events.onEdit(kept + field) + } + + /** Ends the edit with its text; with [closing], the anvil is still open on the client, so the server closes it. */ + private fun finish( + current: PromptAnvil, + closing: Boolean, + ) { + val text = kept + field + reset() + synced(seeding = false) { + if (closing && player.openInventory === current) player.closeInventory() + restore() + } + events.onFinish(text) + } + + /** The anvil lost its viewer while still open: another inventory replaced it, or the server closed it. */ + private fun displaced(closing: PromptAnvil) { + if (closing !== anvil) return + reset() + restore() + events.onDisplaced() + } + + private fun reset() { + anvil = null + window = NO_WINDOW + awaiting = null + } + + /** Puts back the HUD band, the player's slots, and their XP, which the client may have predicted paying. */ + private fun restore() { + sendBand() + restoreSlots() + player.sendPacket(SetExperiencePacket(player.exp, player.level, 0)) + } + + /** Sends the anvil's slots with the player's slots blank, no cursor item, and no repair cost. */ + private fun sendSlots(target: PromptAnvil) { + val items = List(SLOTS) { slot -> if (slot < ANVIL_SLOTS) target.getItemStack(slot) else blank } + player.sendPacket(WindowItemsPacket(target.windowId.toInt(), 0, items, ItemStack.AIR)) + player.sendPacket(WindowPropertyPacket(target.windowId.toInt(), REPAIR_COST, 0)) + } + + /** Runs [send] in a bundle, ending in the ping that settles it when [seeding]. */ + private inline fun synced( + seeding: Boolean, + send: () -> T, + ): T { + val id = if (seeding) PINGS.getAndIncrement() else null + if (id != null) awaiting = id + player.sendPacket(BundlePacket()) + try { + return send().also { if (id != null) player.sendPacket(PingPacket(id)) } + } finally { + player.sendPacket(BundlePacket()) + } + } + + private fun seed(text: String): ItemStack = + unenchanted + .with(DataComponents.CUSTOM_NAME, Component.text(text)) + .with(DataComponents.LORE, listOf(Component.text(seeds++))) + + /** An anvil that reports when its viewer is removed, whether it closes or another inventory replaces it. */ + private inner class PromptAnvil : AnvilInventory(Component.empty()) { + override fun removeViewer(player: Player): Boolean { + if (player in viewers) displaced(this) + return super.removeViewer(player) + } + } + + private companion object { + const val INPUT_SLOT = 0 + + /** The anvil's own slots: two inputs and the result. */ + const val ANVIL_SLOTS = 3 + + /** The anvil's slots and the player's 36. */ + const val SLOTS = ANVIL_SLOTS + 36 + + /** The most characters the anvil's rename field holds. */ + const val MAX_LENGTH = 50 + + /** The anvil window property carrying the repair cost. */ + const val REPAIR_COST: Short = 0 + const val NO_WINDOW = Int.MIN_VALUE + + /** Ping ids start in a range unlikely to collide with other users of the ping packet. */ + val PINGS = AtomicInteger(0x57_46_00_00) + } +} diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index 9889cd2d..fe3247ff 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -1,5 +1,6 @@ package com.chunkzero.window.minestom.internal +import com.chunkzero.window.ExperimentalFullscreenApi import com.chunkzero.window.FullscreenDefinition import com.chunkzero.window.host.FullscreenListener import com.chunkzero.window.host.OpenFullscreen @@ -30,6 +31,7 @@ import net.minestom.server.event.player.PlayerPacketOutEvent import net.minestom.server.event.player.PlayerSpawnEvent import net.minestom.server.item.ItemStack import net.minestom.server.item.Material +import net.minestom.server.item.component.TooltipDisplay import net.minestom.server.network.packet.client.play.ClientAnimationPacket import net.minestom.server.network.packet.client.play.ClientAttackPacket import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket @@ -76,6 +78,8 @@ import net.minestom.server.world.clock.WorldClock * client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. While the server has a * container open, the client gets the real world age back and its real hotbar so the strip and slots show. The text * prompt's dialog is a client screen rather than a server container, so it keeps the HUD hidden and the hotbar blank. + * The keyboard prompt's anvil is a server container, but not one the player sees: while it is open the client gets the + * prompt's world age band, which also hides the anvil, and blanks in every inventory slot the anvil would show. * * While the window is open, a server update to the game mode, abilities, hotbar, time, max health, or absorption passes * through unchanged and is followed by a packet that puts the hidden state back. Replacing the update instead would @@ -96,8 +100,8 @@ import net.minestom.server.world.clock.WorldClock * not a player, so their max health and absorption are left alone. * * A prompt's callback runs on the player's thread after the state it reports has changed, never under [lock]: a server - * container opening over the dialog cancels it on the next tick, and a window ending cancels it once the player is - * restored, just before [FullscreenListener.onClose]. + * container opening over the dialog or replacing the anvil cancels it on the next tick, and a window ending cancels it + * once the player is restored, just before [FullscreenListener.onClose]. */ internal class MinestomFullscreen private constructor( private val player: Player, @@ -110,6 +114,7 @@ internal class MinestomFullscreen private constructor( .builder(Material.PAPER) .set(DataComponents.ITEM_MODEL, definition.blankItemModel.asString()) .set(DataComponents.CUSTOM_NAME, Component.empty()) + .set(DataComponents.TOOLTIP_DISPLAY, TooltipDisplay(true, emptySet())) .build() private val gameMode = player.gameMode private val allowFlying = player.isAllowFlying @@ -137,7 +142,15 @@ internal class MinestomFullscreen private constructor( /** The held slot the client has selected, which held-slot changes are measured from. Requires [lock]. */ private var clientSlot = heldSlot.toInt() - private val textPrompt = MinestomTextPrompt(player) + private val textPrompt = + MinestomTextPrompt(player, blank, ::sendHiddenTime) { + whileOpen { + if (!containerOpen) { + player.inventory.update(player) + blankHotbar() + } + } + } /** The window id of the server container the client has open, or [NO_CONTAINER]. */ private var containerWindowId = NO_CONTAINER @@ -150,8 +163,10 @@ internal class MinestomFullscreen private constructor( get() = player.latency override fun show(content: Component) { - display.editEntityMeta(TextDisplayMeta::class.java) { it.text = content } - player.sendPacket(display.metadataPacket) + whileOpen { + display.editEntityMeta(TextDisplayMeta::class.java) { it.text = content } + player.sendPacket(display.metadataPacket) + } } override fun turn( @@ -164,12 +179,21 @@ internal class MinestomFullscreen private constructor( override val promptOpen: Boolean get() = textPrompt.current != null + @ExperimentalFullscreenApi override fun promptText( + initial: String, + onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) { + if (isOpen()) textPrompt.openKeyboard(initial, onEdit, onDone) else onDone(null) + } + + override fun promptTextDialog( title: Component, initial: String, onDone: (String?) -> Unit, ) { - if (isOpen()) textPrompt.open(title, initial, onDone) else onDone(null) + if (isOpen()) textPrompt.openDialog(title, initial, onDone) else onDone(null) } override fun close() { @@ -279,8 +303,14 @@ internal class MinestomFullscreen private constructor( if (horse == null) player.sendPacket(EntityMetaDataPacket(player.entityId, mapOf(ABSORPTION to ZERO))) } + /** Sends the HUD band's world age, or the prompt band's while the keyboard's anvil is open. */ private fun hideTime(clocks: Map, SetTimePacket.ClockState>) { - player.sendPacket(SetTimePacket(canvas.hudHiddenWorldAge, clocks)) + val age = if (textPrompt.keyboardOpen) canvas.promptWorldAge else canvas.hudHiddenWorldAge + player.sendPacket(SetTimePacket(age, clocks)) + } + + private fun sendHiddenTime() { + whileOpen { if (!containerOpen) hideTime(realTime().clocks()) } } private fun realTime() = player.instance.createTimePacket() @@ -317,6 +347,7 @@ internal class MinestomFullscreen private constructor( } private fun onPacket(event: PlayerPacketEvent) { + textPrompt.onPacket(event) when (val packet = event.packet) { is ClientPlayerRotationPacket -> { listener.onLook(packet.yaw(), packet.pitch()) @@ -420,7 +451,7 @@ internal class MinestomFullscreen private constructor( } is SetPlayerInventorySlotPacket, is SetSlotPacket, is WindowItemsPacket -> { - if (!containerOpen) blank(realHotbarSlots(packet)) + if (!containerOpen) blank(realSlots(packet)) } is ShowDialogPacket, is ClearDialogPacket -> { @@ -428,6 +459,7 @@ internal class MinestomFullscreen private constructor( } is OpenWindowPacket -> { + if (textPrompt.ownsOpen(packet.windowId())) return containerWindowId = packet.windowId() containerGeneration++ // The container replaces the client's dialog without any answer. @@ -456,29 +488,43 @@ internal class MinestomFullscreen private constructor( } } - /** The hotbar slots [packet] sets to something other than the blank. */ - private fun realHotbarSlots(packet: ServerPacket): List = - when { - packet is SetPlayerInventorySlotPacket && packet.slot() in 0 until HOTBAR_SIZE && - packet.itemStack() != blank -> { + /** + * The player inventory slots [packet] sets to something other than the blank, among those the client shows: the + * hotbar, or every storage slot while the keyboard's anvil shows them. + */ + private fun realSlots(packet: ServerPacket): List { + val shown = if (textPrompt.keyboardOpen) 0 until STORAGE_SIZE else 0 until HOTBAR_SIZE + return when { + packet is SetPlayerInventorySlotPacket && packet.slot() in shown && packet.itemStack() != blank -> { listOf(packet.slot()) } - packet is SetSlotPacket && packet.windowId() == 0 && packet.slot().toInt() in WINDOW_HOTBAR && + packet is SetSlotPacket && packet.windowId() == 0 && inventorySlot(packet.slot().toInt()) in shown && packet.itemStack() != blank -> { - listOf(packet.slot() - WINDOW_HOTBAR.first) + listOf(inventorySlot(packet.slot().toInt())) } packet is WindowItemsPacket && packet.windowId() == 0 -> { - (0 until HOTBAR_SIZE).filter { slot -> - packet.items().getOrNull(WINDOW_HOTBAR.first + slot)?.let { it != blank } == true - } + packet + .items() + .withIndex() + .filter { (slot, item) -> inventorySlot(slot) in shown && item != blank } + .map { inventorySlot(it.index) } } else -> { emptyList() } } + } + + /** The player inventory slot of player-window slot [slot], or -1 for crafting, armor, and the off hand. */ + private fun inventorySlot(slot: Int): Int = + when (slot) { + in WINDOW_HOTBAR -> slot - WINDOW_HOTBAR.first + in WINDOW_MAIN -> slot + else -> -1 + } /** Hides the HUD again once the server's container is gone. Requires [lock]. */ private fun containerClosed() { @@ -548,7 +594,7 @@ internal class MinestomFullscreen private constructor( player.sendPacket(player.metadataPacket) player.sendPacket(UpdateHealthPacket(player.health, player.food, player.foodSaturation)) player.setHeldItemSlot(heldSlot) - player.inventory.update() + player.inventory.update(player) } companion object { @@ -558,6 +604,12 @@ internal class MinestomFullscreen private constructor( private const val HOTBAR_SIZE = 9 private const val NO_CONTAINER = -1 + /** The player inventory's hotbar and main grid. */ + private const val STORAGE_SIZE = 36 + + /** The main grid's slots in the player inventory window, after crafting and armor, which match its own. */ + private val WINDOW_MAIN = 9..35 + /** The hotbar's slots in the player inventory window, which follows crafting, armor, and the main grid. */ private val WINDOW_HOTBAR = 36..44 private val ABILITY_FLAGS = diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt index c3789ae4..b19f4940 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt @@ -12,35 +12,73 @@ import net.minestom.server.dialog.DialogInput import net.minestom.server.dialog.DialogMetadata import net.minestom.server.entity.Player import net.minestom.server.event.player.PlayerCustomClickEvent +import net.minestom.server.event.player.PlayerPacketEvent +import net.minestom.server.item.ItemStack import net.minestom.server.network.packet.server.ServerPacket import net.minestom.server.network.packet.server.common.ClearDialogPacket import net.minestom.server.network.packet.server.common.ShowDialogPacket import java.util.concurrent.atomic.AtomicLong /** - * The dialog a full-screen window shows [player] to type into: a text field over a Confirm and a Cancel button, which - * report through custom click actions whose ids are unique to the prompt, so responses to any other dialog are ignored. + * The text prompt of [player]'s full-screen window, and the one owner of its state: [current] is the open prompt, a + * native dialog or the anvil keyboard ([MinestomAnvilKeyboard]), and opening either kind replaces it, taking the + * replaced kind off the client first. * - * The client closes the dialog when a button is pressed, and Escape does nothing, so a prompt ends with a button, with - * another prompt replacing it, or with [cancel]. Each of those reports to its callback once, after the prompt has left - * [current], so a callback may open the next prompt. The buttons sit in the dialog's body, clear of the footer, which - * falls inside the strip the pack's shader clips while the HUD is hidden. + * The dialog has a text field over a Confirm and a Cancel button, which report through custom click actions whose ids + * are unique to the prompt, so responses to any other dialog are ignored. The client closes the dialog when a button + * is pressed, and Escape does nothing. The buttons sit in the dialog's body, clear of the footer, which falls inside + * the strip the pack's shader clips while the HUD is hidden. The keyboard ends with its text when the player clicks or + * presses Escape. + * + * Either kind also ends with `null` when replaced, when a server container takes over (on the next tick), or with + * [cancel]. Each end reports to its callback once, after the prompt has left [current], so a callback may open the + * next prompt. */ internal class MinestomTextPrompt( private val player: Player, + blank: ItemStack, + sendBand: () -> Unit, + restoreSlots: () -> Unit, ) { @Volatile private var pending: Pending? = null - /** The prompt whose dialog the writer has sent most recently and not cleared, which may no longer be [current]. */ + /** The dialog the writer has sent most recently and not cleared, which may no longer be [current]. */ @Volatile - private var shown: Pending? = null + private var shown: Pending.Dialog? = null + + private val keyboard = + MinestomAnvilKeyboard( + player, + blank, + sendBand, + restoreSlots, + object : MinestomAnvilKeyboard.Events { + override fun onEdit(text: String) { + (pending as? Pending.Keyboard)?.onEdit(text) + } + + override fun onFinish(text: String) { + val current = pending as? Pending.Keyboard ?: return + pending = null + current.onDone(text) + } + + override fun onDisplaced() { + val displaced = pending as? Pending.Keyboard ?: return + player.scheduler().scheduleNextTick { cancel(displaced) } + } + }, + ) /** The prompt awaiting an answer, if any. */ val current: Pending? get() = pending + /** Whether the keyboard's anvil is open, so the world age belongs in the prompt band. */ + val keyboardOpen: Boolean get() = keyboard.isOpen + /** Shows the dialog, first reporting `null` to the prompt it replaces. */ - fun open( + fun openDialog( title: Component, initial: String, onDone: (String?) -> Unit, @@ -48,21 +86,45 @@ internal class MinestomTextPrompt( val id = PROMPTS.getAndIncrement() val confirm = Key.key("window", "prompt/$id/confirm") val cancel = Key.key("window", "prompt/$id/cancel") - val show = ShowDialogPacket(dialog(title, initial.take(MAX_LENGTH), confirm, cancel)) - val next = Pending(confirm, cancel, show, onDone) + val show = ShowDialogPacket(dialog(title, initial.take(DIALOG_MAX_LENGTH), confirm, cancel)) + val next = Pending.Dialog(confirm, cancel, show, onDone) val replaced = pending pending = next + keyboard.close() player.sendPacket(next.show) replaced?.onDone?.invoke(null) } - /** Reports a button press of the open prompt, and nothing else. */ + /** + * Opens the keyboard, first reporting `null` to the prompt it replaces. A keyboard replacing a keyboard keeps its + * anvil open. When an inventory listener refuses the anvil, the new prompt reports `null` as well. + */ + fun openKeyboard( + initial: String, + onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) { + val next = Pending.Keyboard(onEdit, onDone) + val replaced = pending + pending = next + if (replaced is Pending.Dialog) player.sendPacket(ClearDialogPacket()) + val opened = keyboard.open(initial) + try { + replaced?.onDone?.invoke(null) + } finally { + if (!opened) cancel(next) + } + } + + /** Reports a button press of the open dialog, and nothing else. */ fun onClick(event: PlayerCustomClickEvent) { - val current = pending ?: return + val current = pending as? Pending.Dialog ?: return val text = when (event.key) { current.confirm -> { - ((event.payload as? CompoundBinaryTag)?.get(FIELD) as? StringBinaryTag)?.value()?.take(MAX_LENGTH) + ((event.payload as? CompoundBinaryTag)?.get(FIELD) as? StringBinaryTag) + ?.value() + ?.take(DIALOG_MAX_LENGTH) ?: return } @@ -78,15 +140,24 @@ internal class MinestomTextPrompt( current.onDone(text) } - /** Takes the open prompt's dialog, if any, off the client's screen; the prompt itself ends with [cancel]. */ + /** Reports the keyboard's edits and its end. */ + fun onPacket(event: PlayerPacketEvent) { + keyboard.onPacket(event) + } + + /** Whether the open packet being written for [windowId] is the keyboard's; see [MinestomAnvilKeyboard.ownsOpen]. */ + fun ownsOpen(windowId: Int): Boolean = keyboard.ownsOpen(windowId) + + /** Takes the open prompt off the client, if any; the prompt itself ends with [cancel]. */ fun clear() { - if (pending != null) player.sendPacket(ClearDialogPacket()) + if (pending is Pending.Dialog) player.sendPacket(ClearDialogPacket()) + keyboard.close() } /** Records, from the packets as the writer sends them, which prompt's dialog the client is showing. */ fun onWritten(packet: ServerPacket) { when (packet) { - is ShowDialogPacket -> shown = pending?.takeIf { it.show == packet } + is ShowDialogPacket -> shown = (pending as? Pending.Dialog)?.takeIf { it.show == packet } is ClearDialogPacket -> shown = null else -> Unit } @@ -94,7 +165,8 @@ internal class MinestomTextPrompt( /** * Cancels the open prompt on the player's next tick when a container replaces the dialog the client is showing. - * A prompt whose dialog has not been sent yet is unaffected, as its dialog arrives after the container. + * A prompt whose dialog has not been sent yet is unaffected, as its dialog arrives after the container. The + * keyboard's anvil reports its own replacement. */ fun coverByContainer() { val covered = shown?.takeIf { it === pending } ?: return @@ -107,6 +179,7 @@ internal class MinestomTextPrompt( val current = pending ?: return if (only != null && only !== current) return pending = null + if (current is Pending.Keyboard) keyboard.close() current.onDone(null) } @@ -124,7 +197,9 @@ internal class MinestomTextPrompt( false, DialogAfterAction.CLOSE, emptyList(), - listOf(DialogInput.Text(FIELD, DialogInput.DEFAULT_WIDTH, title, false, initial, MAX_LENGTH, null)), + listOf( + DialogInput.Text(FIELD, DialogInput.DEFAULT_WIDTH, title, false, initial, DIALOG_MAX_LENGTH, null), + ), ), listOf(button("Confirm", confirm), button("Cancel", cancel)), null, @@ -136,16 +211,25 @@ internal class MinestomTextPrompt( id: Key, ) = DialogActionButton(Component.text(label), null, BUTTON_WIDTH, DialogAction.DynamicCustom(id, null)) - class Pending internal constructor( - internal val confirm: Key, - internal val cancel: Key, - internal val show: ShowDialogPacket, + sealed class Pending( internal val onDone: (String?) -> Unit, - ) + ) { + class Dialog internal constructor( + internal val confirm: Key, + internal val cancel: Key, + internal val show: ShowDialogPacket, + onDone: (String?) -> Unit, + ) : Pending(onDone) + + class Keyboard internal constructor( + internal val onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) : Pending(onDone) + } private companion object { const val FIELD = "text" - const val MAX_LENGTH = 256 + const val DIALOG_MAX_LENGTH = 256 const val BUTTON_WIDTH = 100 val PROMPTS = AtomicLong() } diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index f66a5445..e252b869 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -1,5 +1,6 @@ package com.chunkzero.window.minestom +import com.chunkzero.window.ExperimentalFullscreenApi import com.chunkzero.window.FullscreenClick import com.chunkzero.window.FullscreenDefinition import com.chunkzero.window.FullscreenScope @@ -16,6 +17,7 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.ints.shouldBeLessThan import io.kotest.matchers.shouldBe import net.kyori.adventure.key.Key import net.kyori.adventure.nbt.CompoundBinaryTag @@ -49,15 +51,20 @@ import net.minestom.server.item.ItemStack import net.minestom.server.item.Material import net.minestom.server.network.ConnectionState import net.minestom.server.network.packet.client.ClientPacket +import net.minestom.server.network.packet.client.common.ClientPongPacket import net.minestom.server.network.packet.client.play.ClientAttackPacket +import net.minestom.server.network.packet.client.play.ClientClickWindowPacket +import net.minestom.server.network.packet.client.play.ClientClickWindowPacket.ClickType import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket import net.minestom.server.network.packet.client.play.ClientHeldItemChangePacket import net.minestom.server.network.packet.client.play.ClientInputPacket import net.minestom.server.network.packet.client.play.ClientInteractEntityPacket +import net.minestom.server.network.packet.client.play.ClientNameItemPacket import net.minestom.server.network.packet.client.play.ClientPlayerRotationPacket import net.minestom.server.network.packet.server.SendablePacket import net.minestom.server.network.packet.server.ServerPacket import net.minestom.server.network.packet.server.common.ClearDialogPacket +import net.minestom.server.network.packet.server.common.PingPacket import net.minestom.server.network.packet.server.common.ShowDialogPacket import net.minestom.server.network.packet.server.play.CameraPacket import net.minestom.server.network.packet.server.play.ChangeGameStatePacket @@ -71,12 +78,14 @@ import net.minestom.server.network.packet.server.play.OpenWindowPacket import net.minestom.server.network.packet.server.play.PlayerAbilitiesPacket import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket import net.minestom.server.network.packet.server.play.RespawnPacket +import net.minestom.server.network.packet.server.play.SetExperiencePacket import net.minestom.server.network.packet.server.play.SetPassengersPacket import net.minestom.server.network.packet.server.play.SetPlayerInventorySlotPacket import net.minestom.server.network.packet.server.play.SetSlotPacket import net.minestom.server.network.packet.server.play.SetTimePacket import net.minestom.server.network.packet.server.play.SpawnEntityPacket import net.minestom.server.network.packet.server.play.WindowItemsPacket +import net.minestom.server.network.packet.server.play.WindowPropertyPacket import net.minestom.server.network.player.GameProfile import net.minestom.server.network.player.PlayerConnection import net.minestom.server.registry.RegistryKey @@ -196,7 +205,7 @@ class MinestomFullscreenTest : fixture.tick() fixture.clear() - menu.prompt("hi") + menu.dialog("hi") val dialog = fixture.sent().single().dialog() as Dialog.MultiAction (dialog.metadata().inputs().single() as DialogInput.Text).initial() shouldBe "hi" dialog.metadata().canCloseWithEscape() shouldBe false @@ -212,7 +221,7 @@ class MinestomFullscreenTest : val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() - menu.prompt("hi") + menu.dialog("hi") val dialog = fixture.sent().single().dialog() as Dialog.MultiAction fixture.click(dialog, 1) @@ -224,9 +233,9 @@ class MinestomFullscreenTest : val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() - menu.prompt("first") + menu.dialog("first") val first = fixture.sent().single().dialog() as Dialog.MultiAction - menu.prompt("second") + menu.dialog("second") menu.prompts shouldContainExactly listOf(null) val second = fixture.sent().last().dialog() as Dialog.MultiAction @@ -242,7 +251,7 @@ class MinestomFullscreenTest : val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() - menu.prompt("hi") + menu.dialog("hi") val dialog = fixture.sent().single().dialog() as Dialog.MultiAction fixture.clickCanvas() @@ -265,7 +274,7 @@ class MinestomFullscreenTest : fixture.clear() fixture.player.openInventory(Inventory(InventoryType.CHEST_1_ROW, Component.empty())) - menu.prompt("hi") + menu.dialog("hi") val dialog = fixture.sent().single().dialog() as Dialog.MultiAction fixture.tick() fixture.click(dialog, 0, "text" to "kept") @@ -277,7 +286,7 @@ class MinestomFullscreenTest : val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() - menu.prompt("hi") + menu.dialog("hi") EventDispatcher.call(PlayerDisconnectEvent(fixture.player)) EventDispatcher.call(PlayerDisconnectEvent(fixture.player)) @@ -290,7 +299,7 @@ class MinestomFullscreenTest : val fixture = FullscreenFixture() val menu = Menu(fixture.player) val session = menu.open() - menu.prompt("hi") + menu.dialog("hi") val dialog = fixture.sent().single().dialog() as Dialog.MultiAction fixture.clear() @@ -303,9 +312,9 @@ class MinestomFullscreenTest : val other = Menu(sneaking.player) other.open() var mode: GameMode? = null - other.prompt("hi") { + other.dialog("hi") { mode = sneaking.player.gameMode - other.prompt("again") + other.dialog("again") } sneaking.receive(ClientInputPacket(false, false, false, false, false, true, false)) mode shouldBe GameMode.SURVIVAL @@ -316,12 +325,182 @@ class MinestomFullscreenTest : val failing = FullscreenFixture() val broken = Menu(failing.player) val failed = broken.open() - broken.prompt("hi") { error("boom") } + broken.dialog("hi") { error("boom") } shouldThrow { failed.close() } failing.player.gameMode shouldBe GameMode.SURVIVAL broken.closed shouldBe true } + "the keyboard opens an untitled anvil in the prompt band over blank slots, and Escape finishes with the text" { + val fixture = FullscreenFixture() + val player = fixture.player + player.inventory.setItemStack(2, ItemStack.of(Material.DIAMOND)) + val menu = Menu(player) + menu.open() + fixture.tick() + fixture.clear() + + menu.type("hi") + val open = fixture.sent().single() + open.windowType() shouldBe InventoryType.ANVIL.windowType + open.title() shouldBe Component.empty() + fixture.sent().single().gameTime() shouldBe PROMPT_AGE + val items = fixture.sent().last { it.windowId() == open.windowId() }.items() + items[0].get(DataComponents.CUSTOM_NAME) shouldBe Component.text("hi") + items.drop(3).map { it.get(DataComponents.ITEM_MODEL) }.distinct() shouldBe + listOf("window:fullscreen/blank") + fixture.sent().single().value() shouldBe 0.toShort() + + // The field's echo of its seed arrives before the pong and is not an edit. + fixture.receive(ClientNameItemPacket("hi")).isCancelled shouldBe true + fixture.pong() + fixture.receive(ClientNameItemPacket("hey")) + fixture.receive(ClientNameItemPacket("hey")) + menu.edits shouldContainExactly listOf("hey") + fixture.clickCanvas() + menu.clicks shouldBe emptyList() + + fixture.clear() + fixture.closeWindow(open.windowId()) + menu.prompts shouldContainExactly listOf("hey") + player.openInventory shouldBe null + fixture.sent().single().gameTime() shouldBe HIDDEN_AGE + fixture.sent().size shouldBe 1 + fixture.hotbar() shouldBe List(9) { Material.PAPER } + fixture.clickCanvas() + menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) + } + + "a click anywhere in the anvil's window, even outside it, finishes with the text and closes the anvil" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.type("hi") + val window = fixture.window() + fixture.clear() + + fixture.clickWindow(window, -999).isCancelled shouldBe true + + menu.prompts shouldContainExactly listOf("hi") + fixture.player.openInventory shouldBe null + fixture.sent().single().windowId() shouldBe window + fixture.sent().single().gameTime() shouldBe HIDDEN_AGE + } + + "a prompt replacing one of the other kind takes it off the client first; a keyboard keeps its anvil" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + menu.open() + fixture.tick() + fixture.clear() + + menu.type("a") + val window = fixture.window() + menu.type("b") + menu.prompts shouldContainExactly listOf(null) + fixture.sent().size shouldBe 1 + fixture + .sent() + .last() + .items()[0] + .get(DataComponents.CUSTOM_NAME) shouldBe + Component.text("b") + + fixture.clear() + menu.dialog("c") + menu.prompts shouldContainExactly listOf(null, null) + player.openInventory shouldBe null + fixture.sent().single().gameTime() shouldBe HIDDEN_AGE + fixture.sent().size shouldBe 1 + val closed = fixture.delivered.indexOfFirst { it is CloseWindowPacket && it.windowId() == window } + closed shouldBeLessThan fixture.delivered.indexOfFirst { it is ShowDialogPacket } + + fixture.clear() + menu.type("d") + menu.prompts shouldContainExactly listOf(null, null, null) + fixture.sent().single().gameTime() shouldBe PROMPT_AGE + val cleared = fixture.delivered.indexOfFirst { it is ClearDialogPacket } + cleared shouldBeLessThan fixture.delivered.indexOfFirst { it is OpenWindowPacket } + } + + "ending the window mid-edit restores the band, slots, and XP, then reports null once before onClose" { + val fixture = FullscreenFixture() + val player = fixture.player + player.inventory.setItemStack(2, ItemStack.of(Material.DIAMOND)) + val menu = Menu(player) + menu.open() + fixture.tick() + var mode: GameMode? = null + menu.type("hi") { mode = player.gameMode } + fixture.clear() + + fixture.receive(ClientInputPacket(false, false, false, false, false, true, false)) + + mode shouldBe GameMode.SURVIVAL + menu.prompts shouldContainExactly listOf(null) + menu.closes shouldBe 1 + player.openInventory shouldBe null + fixture.sent().size shouldBe 1 + fixture.sent().last().gameTime() shouldBe player.instance.worldAge + fixture.sent().size shouldBe 1 + fixture.hotbar()[2] shouldBe Material.DIAMOND + fixture.tick() + menu.prompts shouldContainExactly listOf(null) + } + + "a server container replacing the anvil resolves null on the next tick and re-enables the canvas" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + menu.open() + menu.type("hi") + fixture.clear() + + player.openInventory(Inventory(InventoryType.CHEST_1_ROW, Component.empty())) + menu.prompts shouldBe emptyList() + fixture.sent().last() shouldBe player.instance.createTimePacket() + fixture.tick() + fixture.clickCanvas() + + menu.prompts shouldContainExactly listOf(null) + menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) + } + + "a full field continues in a fresh one, keeping its text, and drops renames until the fresh one settles" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + val start = "x".repeat(55) + menu.type(start) + val window = fixture.window() + fixture + .sent() + .last() + .items()[0] + .get(DataComponents.CUSTOM_NAME) shouldBe + Component.text("xxxxx") + fixture.pong() + fixture.clear() + + val full = "xxxxx" + "y".repeat(45) + fixture.receive(ClientNameItemPacket(full)) + menu.edits shouldContainExactly listOf("x".repeat(50) + full) + fixture + .sent() + .last() + .items()[0] + .get(DataComponents.CUSTOM_NAME) shouldBe + Component.text("") + fixture.receive(ClientNameItemPacket("lost")) + fixture.pong() + fixture.receive(ClientNameItemPacket("z")) + fixture.closeWindow(window) + + menu.edits.last() shouldBe "x".repeat(50) + full + "z" + menu.prompts shouldContainExactly listOf("x".repeat(50) + full + "z") + } + "sneaking leaves the window and restores the player" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) @@ -645,9 +824,11 @@ class MinestomFullscreenTest : }) { private companion object { const val HIDDEN_AGE = -1_000_000_001L + const val PROMPT_AGE = -999_988_001L } } +@OptIn(ExperimentalFullscreenApi::class) private class Menu( player: Player, mode: FullscreenMode = FullscreenMode.UNLOCKED, @@ -656,6 +837,7 @@ private class Menu( val rightClicks = mutableListOf() val scrolls = mutableListOf() val prompts = mutableListOf() + val edits = mutableListOf() var closes = 0 val closed get() = closes > 0 @@ -665,10 +847,18 @@ private class Menu( scroll { scrolls += it } } - fun prompt( + fun dialog( initial: String, then: () -> Unit = {}, - ) = promptText(initial = initial) { + ) = promptTextDialog(initial = initial) { + prompts += it + then() + } + + fun type( + initial: String = "", + then: () -> Unit = {}, + ) = promptText(initial, onEdit = { edits += it }) { prompts += it then() } @@ -720,6 +910,25 @@ private class FullscreenFixture { fun receive(packet: ClientPacket) = PlayerPacketEvent(player, packet).also(EventDispatcher::call) + /** The window id of the latest window the client was told to open. */ + fun window(): Int = sent().last().windowId() + + /** Answers the latest ping, which settles the keyboard's latest seed. */ + fun pong() { + receive(ClientPongPacket(sent().last().id())) + } + + /** Closes window [id] from the client, as Escape does, then lets Minestom close it unless the event was cancelled. */ + fun closeWindow(id: Int) { + if (!receive(ClientCloseWindowPacket(id)).isCancelled) player.closeInventory(true, id.toByte()) + } + + /** Clicks [slot] of window [id], -999 being outside it. */ + fun clickWindow( + id: Int, + slot: Int, + ) = receive(ClientClickWindowPacket(id, 0, slot.toShort(), 0, ClickType.PICKUP, emptyMap(), ItemStack.Hash.AIR)) + /** Presses [dialog]'s button [index], answering with [fields]. */ fun click( dialog: Dialog.MultiAction, @@ -817,6 +1026,7 @@ private fun manifest(mode: FullscreenMode): WindowManifest { backdrop = "B", overlayMarker = "#a3f800", hudHiddenWorldAge = -1_000_000_001, + promptWorldAge = -999_988_001, hudStripHeight = 56, hudStripHalfWidth = 96, hudMaxHealth = 20, diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/ExperimentalFullscreenApi.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/ExperimentalFullscreenApi.kt new file mode 100644 index 00000000..7f032b0c --- /dev/null +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/ExperimentalFullscreenApi.kt @@ -0,0 +1,13 @@ +package com.chunkzero.window + +/** + * Marks full-screen window API that relies on client behaviour Window cannot fully control, such as the keyboard text + * prompt, which disguises a vanilla anvil. Its behaviour and signature may change between releases. + */ +@RequiresOptIn( + message = "This full-screen window API is experimental and may change between releases.", + level = RequiresOptIn.Level.WARNING, +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +public annotation class ExperimentalFullscreenApi diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index d30a51d4..916c9b28 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -24,6 +24,7 @@ public sealed class FullscreenSession { /** * A full-screen window shown through [host]: maps the player's rotation to the canvas cursor, hit-tests clicks, draws * the hovered control's outline and tooltip, and re-sends the runs when reactive state or the hovered control changes. + * While a text prompt is open, canvas input is ignored and the tooltip is left out. * * A player turned more than a degree past a canvas edge is turned back to it, at most once per round trip: rotations * the client sent before applying a correction still carry the overshoot. @@ -78,6 +79,8 @@ internal class FullscreenWindowSession( /** Whether canvas input counts: the window is open and no prompt covers it. */ private val listening get() = !closed && !screen.promptOpen + private val prompting get() = ::screen.isInitialized && screen.promptOpen + fun open() { try { view.attach(this, reactivity) @@ -105,14 +108,44 @@ internal class FullscreenWindowSession( } } + @OptIn(ExperimentalFullscreenApi::class) fun promptText( + initial: String, + onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) { + if (closed) return onDone(null) + val edit = { text: String -> + onEdit(text) + if (!closed) reactivity.flushNow() + } + screen.promptText(initial, edit, ended(onDone)) + show() + } + + fun promptTextDialog( title: Component, initial: String, onDone: (String?) -> Unit, ) { - if (closed) onDone(null) else screen.promptText(title, initial, onDone) + if (closed) return onDone(null) + screen.promptTextDialog(title, initial, ended(onDone)) + show() } + /** [onDone], then what it changed and the tooltip the prompt hid, at once rather than on the next tick. */ + private fun ended(onDone: (String?) -> Unit): (String?) -> Unit = + { text -> + try { + onDone(text) + } finally { + if (!closed) { + reactivity.flushNow() + show() + } + } + } + fun refreshAll() { reactivity.markAllDirty(layers.renderKeys()) } @@ -176,7 +209,7 @@ internal class FullscreenWindowSession( .firstOrNull { (key, control) -> layers.switches.regionActive(key) && mapping.hits(control, point) } ?.key - /** The backdrop, the window's layers, the hovered control's tooltip and outline, and the cursor. */ + /** The backdrop, the window's layers, the hovered control's tooltip unless prompting, its outline, and the cursor. */ private fun content(): Component = Component .text() @@ -184,7 +217,7 @@ internal class FullscreenWindowSession( listOfNotNull( definition.backdrop, layout, - hovered?.let(definition.tooltips::get), + hovered?.takeUnless { prompting }?.let(definition.tooltips::get), hovered?.let(definition.outlines::get), definition.cursor, ), diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index 298f7717..b721b823 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -12,7 +12,8 @@ import kotlin.properties.ReadWriteProperty * reacts to lifecycle events. * * A view shows [definition] to the player of [host] and is single-use: [open] it once. The player aims a cursor by - * turning, clicks with the attack and use keys, scrolls, answers a [promptText] dialog, and leaves by sneaking. + * turning, clicks with the attack and use keys, scrolls, types into a [promptText] or [promptTextDialog] prompt, and + * leaves by sneaking. * Reactive [state] cells read inside render lambdas re-render the window when written. */ public abstract class FullscreenView( @@ -91,31 +92,73 @@ public abstract class FullscreenView( } /** - * Opens a dialog titled [title] over the canvas for the player to type into, starting from [initial], and calls - * [onDone] once with the text when they confirm, or `null` when they cancel. The dialog closes with the button and - * the canvas is back. The text holds at most 256 characters, and [initial] is cut to fit. Escape does not dismiss - * the dialog. While it is open the HUD stays hidden and canvas input is ignored, so the cursor stays where it was. + * Lets the player type on the canvas, starting from [initial]: the keyboard prompt. Nothing of the prompt is drawn, + * so draw the text from [onEdit], which receives the whole text after each edit and re-renders at once rather than + * on the next tick. [onDone] then receives the text once, when the player presses Escape or clicks anywhere. There + * is no cancel; use [promptTextDialog] when the player must be able to back out. While the prompt is open the HUD + * stays hidden, canvas input is ignored, so the cursor stays where it was, and the hovered control's tooltip is not + * drawn. * - * Opening a prompt while one is open replaces it, and the replaced prompt's [onDone] receives `null`. When a server - * container opens over the dialog, the open prompt's [onDone] receives `null` as well, on the next tick, and the + * The prompt is an anvil the pack's shaders hide, so: + * - the server never learns the client's caret, so a drawn caret belongs at the end of the text; + * - the anvil holds 50 characters, so the text moves on in 50-character pieces: the earlier pieces cannot be edited, + * and characters typed while the next piece opens can be lost; + * - GUI text and fills in the anvil's rename field and label bands, such as chat lines crossing them, are hidden + * while it is open; + * - the system mouse pointer shows; + * - each edit costs a round trip before [onEdit] runs; + * - without the pack's shaders, the player sees a plain vanilla anvil. + * + * Opening a prompt of either kind while one is open replaces it, and the replaced prompt's [onDone] receives `null`. + * When a server container opens over the prompt, its [onDone] receives `null` as well, on the next tick, and the * window stays open. When the window closes, it receives `null` once the player is restored. [onDone] runs after its * prompt has ended, so it may open another one; on a closed window it receives `null` at once. * - * To prompt on a click, call this from the control's click handler: * ``` * override fun onRename(click: FullscreenClick) { - * promptText(Component.text("Rename"), name) { it?.let { name = it } } + * draft = name + * promptText(name, onEdit = { draft = it }) { text -> + * draft = null + * text?.let { name = it } + * } * } * ``` * * @throws IllegalStateException if the view was never opened. */ + @ExperimentalFullscreenApi protected fun promptText( + initial: String = "", + onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) { + requireSession().promptText(initial, onEdit, onDone) + } + + /** + * Opens a native dialog titled [title] over the canvas for the player to type into, starting from [initial], and + * calls [onDone] once with the text when they confirm, or `null` when they cancel. The dialog closes with the button + * and the canvas is back. The text holds at most 256 characters, and [initial] is cut to fit. Escape does not + * dismiss the dialog. While it is open the HUD stays hidden, canvas input is ignored, and the hovered control's + * tooltip is not drawn. + * + * Unlike [promptText], the dialog covers the canvas, but it offers a cancel, a movable caret, and selection, and + * needs nothing from the pack. Replacement and [onDone] timing are as for [promptText]. + * + * ``` + * override fun onRename(click: FullscreenClick) { + * promptTextDialog(Component.text("Rename"), name) { it?.let { name = it } } + * } + * ``` + * + * @throws IllegalStateException if the view was never opened. + */ + protected fun promptTextDialog( title: Component = Component.empty(), initial: String = "", onDone: (String?) -> Unit, ) { - requireSession().promptText(title, initial, onDone) + requireSession().promptTextDialog(title, initial, onDone) } /** diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index af1fde48..66c85979 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -1,5 +1,6 @@ package com.chunkzero.window.host +import com.chunkzero.window.ExperimentalFullscreenApi import com.chunkzero.window.FullscreenDefinition import net.kyori.adventure.text.Component @@ -32,7 +33,7 @@ public interface OpenFullscreen { /** The player's latency in milliseconds. */ public val latencyMillis: Int - /** Replaces the glyph runs shown. */ + /** Replaces the glyph runs shown; does nothing once the window has ended. */ public fun show(content: Component) /** Turns the player's view by [yaw] and [pitch] degrees from where the client has it. */ @@ -41,9 +42,27 @@ public interface OpenFullscreen { pitch: Float, ) - /** Whether a [promptText] dialog is open and unanswered. */ + /** Whether a [promptText] or [promptTextDialog] prompt is open and unanswered. */ public val promptOpen: Boolean + /** + * Captures text the player types, starting from [initial], while the canvas stays in view; the host draws nothing + * of the prompt, so the window draws the text itself. Reports the whole text to [onEdit] after each edit the client + * sends, and the outcome to [onDone] exactly once, on the player's thread: the text when the player presses Escape + * or clicks anywhere, and `null` when another prompt replaces this one, a server container opens over it, or the + * window ends. There is no cancel. The HUD stays hidden while the prompt is open. + * + * Both kinds of prompt share one slot: opening either replaces an open prompt of either kind. The prompt has left + * [promptOpen] by the time [onDone] runs, so [onDone] may open the next prompt. On a closed window, or when the host + * cannot show the prompt, this reports `null` at once. + */ + @ExperimentalFullscreenApi + public fun promptText( + initial: String, + onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) + /** * Shows a dialog titled [title] over the canvas with a text field holding [initial] and a Confirm and a Cancel * button, and reports the outcome to [onDone] exactly once, on the player's thread: the field's text on Confirm, @@ -51,19 +70,18 @@ public interface OpenFullscreen { * the window ends. The dialog closes with the button, back to the canvas, and cannot be dismissed with Escape. The * text holds at most 256 characters; [initial] is cut to fit. The HUD stays hidden while the dialog is open. * - * The prompt has left [promptOpen] by the time [onDone] runs, so [onDone] may open the next prompt. On a closed - * window this reports `null` at once. + * Replacement and [onDone] timing are as for [promptText]. */ - public fun promptText( + public fun promptTextDialog( title: Component, initial: String, onDone: (String?) -> Unit, ) /** - * Server-initiated close: stops listening, clears an open prompt's dialog, removes the display, restores the player's - * game mode, abilities, position, held slot, and HUD, and only then reports `null` to the open prompt. Does not call - * [FullscreenListener.onClose]. + * Server-initiated close: stops listening, takes an open prompt off the client, removes the display, restores the + * player's game mode, abilities, position, held slot, and HUD, and only then reports `null` to the open prompt. Does + * not call [FullscreenListener.onClose]. */ public fun close() } diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Reactivity.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Reactivity.kt index 33551ce1..395ee6f4 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Reactivity.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Reactivity.kt @@ -78,6 +78,14 @@ internal class Reactivity( scheduleFlush() } + /** Re-renders the dirty keys now rather than on the scheduled tick, which then finds nothing to do. */ + fun flushNow() { + if (dirty.isEmpty()) return + val snapshot = LinkedHashSet(dirty) + dirty.clear() + flush(snapshot) + } + private fun scheduleFlush() { if (flushScheduled) return flushScheduled = true diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index b0e86c95..d9279fcd 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -249,6 +249,29 @@ class FullscreenSessionTest : host.pending shouldBe null } + "a keyboard edit re-renders at once, and no tooltip shows while a prompt of either kind is open" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + host.look(160, 200) + text(host.shown.last()) shouldContain "PANEL" + menu.type("") { menu.suffix = it } + text(host.shown.last()) shouldNotContain "PANEL" + + host.edit("hi") + text(host.shown.last()) shouldContain "N0hi" + text(host.shown.last()) shouldNotContain "PANEL" + + host.answer("hi") + menu.prompts shouldContainExactly listOf("hi") + text(host.shown.last()) shouldContain "PANEL" + + menu.dialog("hi") + text(host.shown.last()) shouldNotContain "PANEL" + host.answer(null) + text(host.shown.last()) shouldContain "PANEL" + } + "horse mode sends no cursor run, since the entity shader draws the cursor, and hit-tests the same rotation" { val host = FakeFullscreenHost() val menu = Menu(host, FullscreenMode.HORSE).also { it.open() } @@ -262,6 +285,7 @@ class FullscreenSessionTest : } }) +@OptIn(ExperimentalFullscreenApi::class) private class Menu( host: FullscreenHost, mode: FullscreenMode = FullscreenMode.UNLOCKED, @@ -298,11 +322,18 @@ private class Menu( fun prompt( initial: String, then: () -> Unit = {}, - ) = promptText(initial = initial) { + ) = promptText(initial, onEdit = {}) { prompts += it then() } + fun type( + initial: String, + onEdit: (String) -> Unit, + ) = promptText(initial, onEdit) { prompts += it } + + fun dialog(initial: String) = promptTextDialog(initial = initial) { prompts += it } + override fun onClose() { closes++ } @@ -315,6 +346,7 @@ private class FakeFullscreenHost : val shown = mutableListOf() val turns = mutableListOf() var pending: ((String?) -> Unit)? = null + private var editing: ((String) -> Unit)? = null var closes = 0 private var open = true lateinit var listener: FullscreenListener @@ -346,17 +378,35 @@ private class FakeFullscreenHost : override val promptOpen get() = pending != null + @ExperimentalFullscreenApi override fun promptText( + initial: String, + onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) = replace(onEdit, onDone) + + override fun promptTextDialog( title: Component, initial: String, onDone: (String?) -> Unit, + ) = replace(null, onDone) + + private fun replace( + onEdit: ((String) -> Unit)?, + onDone: (String?) -> Unit, ) { if (!open) return onDone(null) val replaced = pending pending = onDone + editing = onEdit replaced?.invoke(null) } + /** Reports an edit to the pending keyboard prompt. */ + fun edit(text: String) { + editing?.invoke(text) + } + override fun close() { closes++ open = false @@ -367,6 +417,7 @@ private class FakeFullscreenHost : fun answer(text: String?) { val done = pending ?: return pending = null + editing = null done(text) } From e863938c0f2e49b006a98a8b75fbe66173ffcf64 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:29:28 -0400 Subject: [PATCH 3/8] feat(example): edit the lobby's name in place with the keyboard prompt --- example/pack/src/window/lobby.tsx | 5 ++- .../com/chunkzero/window/example/MyLobby.kt | 32 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/example/pack/src/window/lobby.tsx b/example/pack/src/window/lobby.tsx index 56bc1e0d..ee71c245 100644 --- a/example/pack/src/window/lobby.tsx +++ b/example/pack/src/window/lobby.tsx @@ -24,9 +24,8 @@ const s = create({ }); /** - * A main menu drawn over the whole screen while the world turns behind it. Click the name to rename the foundry in a - * native dialog, pick a world from the paged list (the arrows or the scroll wheel move it), right click anywhere to - * ready up, then play. + * A main menu drawn over the whole screen while the world turns behind it. Click the name to edit it in place, pick a + * world from the paged list (the arrows or the scroll wheel move it), right click anywhere to ready up, then play. */ export default ( diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt index 2ad52bb4..d82e517a 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt @@ -1,5 +1,6 @@ package com.chunkzero.window.example +import com.chunkzero.window.ExperimentalFullscreenApi import com.chunkzero.window.FullscreenClick import com.chunkzero.window.example.generated.LobbyFullscreen import net.kyori.adventure.text.Component @@ -7,20 +8,29 @@ import net.minestom.server.entity.Player import kotlin.math.abs /** - * The full-screen lobby: a paged world list moved by the arrows or the scroll wheel, a foundry name renamed through a - * native dialog, and a ready flag flipped by right clicking anywhere. + * The full-screen lobby: a paged world list moved by the arrows or the scroll wheel, a foundry name edited in place on + * its plate, and a ready flag flipped by right clicking anywhere. */ class MyLobby( player: Player, ) : LobbyFullscreen(player) { private var name by state("${player.username}'s Foundry") private var isReady by state(false) + + /** The name as typed so far while the rename prompt is open. */ + private var draft: String? by state(null) private val worlds = list(cells = WORLD_NAME_SIZE, key = { it: String -> it }, step = 1) { WORLDS } - override fun foundryName(): Component = fit("foundry_name", Component.text(name)) + override fun foundryName(): Component = draft?.let(::editing) ?: fit("foundry_name", Component.text(name)) override fun lobbyStatus(): Component = - Component.text(if (isReady) "Ready for ${worlds.selected}" else "${worlds.selected} selected") + Component.text( + when { + draft != null -> "Type a name, then click or press Esc" + isReady -> "Ready for ${worlds.selected}" + else -> "${worlds.selected} selected" + }, + ) override fun tone(): Tone { val pinned = pinnedTone(player) @@ -49,13 +59,23 @@ class MyLobby( isReady = !isReady } - /** Asks for a new name in a native dialog; the lobby is back once the player confirms or cancels. */ + /** Edits the name in place: the plate shows the text as it is typed, until the player clicks or presses Escape. */ + @OptIn(ExperimentalFullscreenApi::class) override fun onRename(click: FullscreenClick) { - promptText(Component.text("Name your foundry"), name) { text -> + draft = name + promptText(name, onEdit = { draft = it }) { text -> + draft = null text?.trim()?.takeIf(String::isNotEmpty)?.let { name = it } } } + /** [text] and a caret, dropping its start once it outgrows the plate so the end stays in view. */ + private fun editing(text: String): Component = + (0..text.length) + .asSequence() + .map { Component.text(text.substring(it) + "_") } + .first { fit("foundry_name", it) == it } + override fun onPlay(click: FullscreenClick) { if (!isReady) return close() From 96e4951679c4d69e45247845e584eb7d6f666042 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:29:28 -0400 Subject: [PATCH 4/8] docs: document the keyboard and dialog text prompts --- README.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a3a6df1d..876589e5 100644 --- a/README.md +++ b/README.md @@ -203,15 +203,21 @@ player aims by turning, clicks with the attack key, and leaves by sneaking. An e ```kotlin class MyLobby(player: Player) : LobbyFullscreen(player) { private var name by state("${player.username}'s Foundry") + private var draft: String? by state(null) private val worlds = list(cells = 3, key = { it: String -> it }, step = 1) { WORLDS } - override fun foundryName(): Component = Component.text(name) + override fun foundryName(): Component = Component.text(draft?.let { "${it}_" } ?: name) override fun worldName(index: Int): Component = Component.text(worlds.at(index).orEmpty()) - // A click opens a native dialog; the canvas is back once the player confirms or cancels. + // A click lets the player type the name in place, until they click again or press Escape. + @OptIn(ExperimentalFullscreenApi::class) override fun onRename(click: FullscreenClick) { - promptText(Component.text("Name your foundry"), name) { text -> text?.let { name = it } } + draft = name + promptText(name, onEdit = { draft = it }) { text -> + draft = null + text?.let { name = it } + } } override fun onScroll(steps: Int) = repeat(abs(steps)) { if (steps > 0) worlds.next() else worlds.previous() } @@ -225,8 +231,15 @@ MyLobby(player).open() - Handlers receive a `FullscreenClick` with the cursor's canvas position. `onScroll(steps)` gets the scroll wheel as signed steps, and `onRightClick(click)` gets right clicks anywhere on the canvas. - `list(...)` pages a list through a ``'s cells, like `WindowView.list(...)`. -- `promptText(title, initial) { text -> }` shows a native dialog with a text field and Confirm and Cancel buttons. The - callback gets the text, or `null` on Cancel or when the window closes. Escape does not dismiss it. +- Two text prompts share one slot: opening either replaces an open prompt, whose callback gets `null`, as it does when a + server container opens over the prompt or the window closes. While one is open, canvas input is ignored and the + hovered control's tooltip is hidden. + - `promptText(initial, onEdit) { text -> }`, experimental (`@OptIn(ExperimentalFullscreenApi::class)`), lets the + player type while the canvas stays in view. Nothing of it is drawn: draw the text from `onEdit`, which re-renders at + once. Escape or a click anywhere finishes with the text; there is no cancel. + - `promptTextDialog(title, initial) { text -> }` shows a native dialog over the canvas with a text field and Confirm + and Cancel buttons, and gets `null` on Cancel. Escape does not dismiss it. Use it when the player must be able to + back out, or to move the caret. - Views are single-use, as with windows, and a player has at most one full-screen window open. While the window is open, the Minestom host: @@ -237,7 +250,8 @@ While the window is open, the Minestom host: (or the one the server set meanwhile), abilities, position, and held slot. - hides the HUD by holding the client's world age negative, which the pack's shaders read. The hidden strip at the bottom of the screen can clip the player's own inventory (E) at small GUI heights, since the server is not told it - opened. + opened. While the keyboard prompt is open, the age sits in a second negative band in which the shaders also hide the + untitled anvil that captures the typing. - closes the window when the player dies or spawns into another instance. Opening one while the player is dead or respawning throws, so a respawn listener should open it on the next tick. @@ -248,6 +262,11 @@ Limitations: - The client sends at most one held-slot change per tick, so `onScroll` gets one update per tick of at most 4 steps either way, more than 4 notches in one tick read as a scroll the other way, and number keys look like scrolling. - Chat and other GUI overlays draw over the canvas, and there are no real items. +- The keyboard prompt is experimental. The server never sees the caret, so draw it at the end of the text. The anvil + holds 50 characters, so longer text continues in a fresh field: text in earlier fields can no longer be edited, and + keys typed just as the fresh field opens can be lost. While it is open, GUI text and fills in the anvil's rename field + and label bands, such as chat lines crossing them, are hidden, and the system mouse pointer shows. Each edit takes a + round trip to reach `onEdit`. Without the pack's shaders, the player sees a plain vanilla anvil. ## Supported versions From 86053f498443f3c267bf4f66fcf3904fb8deb19a Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:40:30 -0400 Subject: [PATCH 5/8] fix(core): extend the prompt's rename field band one pixel left to cover the selection --- crates/window-core/src/hud/renderer/prompt.rs | 7 ++++--- crates/window-core/src/hud/tests.rs | 2 +- plugin/test/golden/fullscreen/expected.sha256 | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/window-core/src/hud/renderer/prompt.rs b/crates/window-core/src/hud/renderer/prompt.rs index 7303f7ae..7beb70a2 100644 --- a/crates/window-core/src/hud/renderer/prompt.rs +++ b/crates/window-core/src/hud/renderer/prompt.rs @@ -18,9 +18,10 @@ use crate::fullscreen::PROMPT_TIME_SPLIT; /// The anvil screen's size; vanilla centres it with integer division. const ANVIL_SIZE: [u32; 2] = [176, 166]; -/// The rename field's band `[x, y, width, height]`: its text at (62, 24) with a shadow, the caret and selection one -/// pixel above and below it, and an end-of-text caret that may run past the field's 103-pixel width. -const FIELD_BAND: [u32; 4] = [62, 22, 114, 14]; +/// The rename field's band `[x, y, width, height]`: its text at (62, 24) with a shadow, the selection starting one +/// pixel left of it, the caret and selection one pixel above and below it, and an end-of-text caret that may run past +/// the field's 103-pixel width. +const FIELD_BAND: [u32; 4] = [61, 22, 115, 14]; /// The title's band at y 6 and the inventory label's at y 72, nine pixels of text and a pixel of slack each. const LABEL_BANDS: [[u32; 4]; 2] = [[0, 6, 176, 10], [0, 72, 176, 10]]; diff --git a/crates/window-core/src/hud/tests.rs b/crates/window-core/src/hud/tests.rs index d36cc46b..655e26c8 100644 --- a/crates/window-core/src/hud/tests.rs +++ b/crates/window-core/src/hud/tests.rs @@ -156,7 +156,7 @@ fn fullscreen_branch_joins_the_text_shader_and_hides_the_hud_strip() { // While the prompt's anvil is open, GUI text is hidden only in its field and label bands, and fills only in the // field band or when they are the dimming gradient or a slot highlight. let text = &alone.files[1].contents; - assert!(text.contains("const vec4 WINDOW_PROMPT_FIELD = vec4(62.0, 22.0, 114.0, 14.0);"), "{text}"); + assert!(text.contains("const vec4 WINDOW_PROMPT_FIELD = vec4(61.0, 22.0, 115.0, 14.0);"), "{text}"); assert!(text.contains("void main() {\n#ifdef IS_GUI\n if (window_prompt_in(WINDOW_PROMPT_FIELD)"), "{text}"); assert!(alone.files[0].contents.contains("const float WINDOW_PROMPT_TIME_SPLIT = -0.4;")); let gui = &alone.files[4].contents; diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 080065f5..554a2cc2 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -1,9 +1,9 @@ b902022fd657ad3b7b7cd44391210c7c6f2d5086bebba022fecbbee2a951df3f dist/assets/minecraft/shaders/core/entity.vsh -69395c6f856c85ba732d28dff82d67095fbe7a73ae8da9e979a938bc5da1bd63 dist/assets/minecraft/shaders/core/gui.fsh +d56563e462372062c7a8e7a417b4a1b107f0d291ed561b6d74c6a3c0eb8ffa71 dist/assets/minecraft/shaders/core/gui.fsh de99b339c5bc4b28a85d9fe06066c528fa7c10497037c51dca18652360f14aa3 dist/assets/minecraft/shaders/core/gui.vsh -36ccd0bdb0fd5962f45383d9c95192777575ae53cb48762ed62c0585d70f2d62 dist/assets/minecraft/shaders/core/position_tex_color.fsh +13dfc12b3e9554fae3cd1c2c43cd78d92ebd8405c7493b4252da616482ed93cd dist/assets/minecraft/shaders/core/position_tex_color.fsh df4975456519e08d7a7065027ae566a58314fa95d6a7f58764a2bac5638e1e72 dist/assets/minecraft/shaders/core/position_tex_color.vsh -edf376dba2d545c347720fd783e829f024c9381485d14cfbf1bd66d40877b7e3 dist/assets/minecraft/shaders/core/text.fsh +56bb409c4026de938f0a2f77bd3ac9131658aad33facbb9b92145e9095ba0fdd dist/assets/minecraft/shaders/core/text.fsh f6a980f98a5850b6530119b72ef064d6e3365f2176c2ba9c50b7e3ce6524173b dist/assets/minecraft/shaders/core/text.vsh d8c8dc63512f18a6f85b86ce57d473cc2b2b76453ba1c0fd3ba97b47bbb5b26f dist/assets/minecraft/shaders/include/window_fullscreen.glsl 8f0a28c175d2c93ff86aa1377f34c9e8fbc76e922ca34ae2f6db19dd557d285e dist/assets/minecraft/textures/gui/sprites/hud/crosshair.png @@ -35,7 +35,7 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -ead1d487194685043ec9852b21041eeced533874dd0591e4960505aca6491b87 dist/assets/window/window/debug.json +5611a7288a716852a43533923f19ffe054ccf870e7275b31371bead51bb76d8d dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 kotlin-out/MenuFullscreen.kt From 7768a99bb5a5c3129dff18e43821e23ae9f081df Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:40:30 -0400 Subject: [PATCH 6/8] fix(runtime): finish the keyboard prompt with Escape, cap it at 50 characters, and take over open containers Clicks on the rename field or inside the anvil send no packet, so only Escape reliably finishes the prompt; slot and outside clicks remain best-effort. Text past the anvil's 50 characters is no longer continued in a fresh field, as the client cannot edit the kept prefix. A keyboard anvil opening over a server container now replaces it in the fullscreen's container state, so the prompt band applies and finishing restores the hidden band and slots. --- README.md | 9 +-- .../com/chunkzero/window/example/MyLobby.kt | 4 +- .../internal/MinestomAnvilKeyboard.kt | 49 +++++-------- .../minestom/internal/MinestomFullscreen.kt | 10 ++- .../minestom/internal/MinestomTextPrompt.kt | 4 +- .../window/minestom/MinestomFullscreenTest.kt | 71 ++++++++++++++----- .../com/chunkzero/window/FullscreenView.kt | 8 +-- .../chunkzero/window/host/FullscreenHost.kt | 5 +- 8 files changed, 95 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 876589e5..a70c0fd2 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ class MyLobby(player: Player) : LobbyFullscreen(player) { override fun worldName(index: Int): Component = Component.text(worlds.at(index).orEmpty()) - // A click lets the player type the name in place, until they click again or press Escape. + // A click lets the player type the name in place, until they press Escape. @OptIn(ExperimentalFullscreenApi::class) override fun onRename(click: FullscreenClick) { draft = name @@ -236,10 +236,11 @@ MyLobby(player).open() hovered control's tooltip is hidden. - `promptText(initial, onEdit) { text -> }`, experimental (`@OptIn(ExperimentalFullscreenApi::class)`), lets the player type while the canvas stays in view. Nothing of it is drawn: draw the text from `onEdit`, which re-renders at - once. Escape or a click anywhere finishes with the text; there is no cancel. + once. Escape finishes with the text, and a click on a slot or outside the anvil may finish it too; there is no + cancel. The text holds at most 50 characters, and `initial` is cut to fit. - `promptTextDialog(title, initial) { text -> }` shows a native dialog over the canvas with a text field and Confirm - and Cancel buttons, and gets `null` on Cancel. Escape does not dismiss it. Use it when the player must be able to - back out, or to move the caret. + and Cancel buttons, and gets `null` on Cancel. It holds up to 256 characters, and Escape does not dismiss it. Use it + when the player must be able to back out, or to move the caret. - Views are single-use, as with windows, and a player has at most one full-screen window open. While the window is open, the Minestom host: diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt index d82e517a..9ed92e4b 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyLobby.kt @@ -26,7 +26,7 @@ class MyLobby( override fun lobbyStatus(): Component = Component.text( when { - draft != null -> "Type a name, then click or press Esc" + draft != null -> "Type a name, then press Esc" isReady -> "Ready for ${worlds.selected}" else -> "${worlds.selected} selected" }, @@ -59,7 +59,7 @@ class MyLobby( isReady = !isReady } - /** Edits the name in place: the plate shows the text as it is typed, until the player clicks or presses Escape. */ + /** Edits the name in place: the plate shows the text as it is typed, until the player presses Escape. */ @OptIn(ExperimentalFullscreenApi::class) override fun onRename(click: FullscreenClick) { draft = name diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt index e6b78e69..d2184675 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt @@ -26,13 +26,15 @@ import java.util.concurrent.atomic.AtomicInteger * * The input paper is named after the field's text, which vanilla copies into the rename field, and has no * enchantments, so the client computes no repair cost. The client resets its field only when the input slot changes, - * so each seed carries a fresh lore line. A seeding send (an open, or a continuation once the field fills) is bundled - * with a ping; rename packets before its pong come from an earlier anvil or are the seed's echo, and are dropped. The - * field holds [MAX_LENGTH] characters, so a full field moves its text into [kept] and starts empty again. + * so each seed carries a fresh lore line. A seeding send is bundled with a ping; rename packets before its pong come + * from an earlier anvil or are the seed's echo, and are dropped. The field holds at most [MAX_LENGTH] characters, and a + * longer initial text is truncated to fit. * - * The server cannot see the client's caret, so a click anywhere in the anvil's window, or Escape, which closes it, - * finishes the edit with the text so far. Another inventory replacing the anvil displaces it. Each is reported to - * [events] after the anvil's state is reset and the player's band, slots, and XP are put back. + * The server cannot see the client's caret, so Escape, which closes the anvil, finishes the edit with the text so far. + * Clicking a slot or outside the anvil's window also finishes it, but the client sends nothing for a click on the + * rename field or elsewhere inside the window, so a click is only a best-effort way to finish. Another inventory + * replacing the anvil displaces it. Each is reported to [events] after the anvil's state is reset and the player's + * band, slots, and XP are put back. */ internal class MinestomAnvilKeyboard( private val player: Player, @@ -43,10 +45,10 @@ internal class MinestomAnvilKeyboard( ) { /** What the player did with the open anvil. */ interface Events { - /** The field changed; [text] includes the kept pieces. */ + /** The field changed to [text]. */ fun onEdit(text: String) - /** The player clicked or pressed Escape, which closed the anvil. */ + /** The player pressed Escape, or clicked a slot or outside the window; the anvil is closed. */ fun onFinish(text: String) /** Another inventory replaced the anvil, or the server closed it. */ @@ -56,9 +58,6 @@ internal class MinestomAnvilKeyboard( private val unenchanted = blank.without(DataComponents.ENCHANTMENTS) private var anvil: PromptAnvil? = null - /** The text of earlier fields that filled up. */ - private var kept = "" - /** The rename field's text as of the latest edit, or the text it was seeded with. */ private var field = "" @@ -88,8 +87,7 @@ internal class MinestomAnvilKeyboard( fun open(initial: String): Boolean { val reused = anvil?.takeIf { player.openInventory === it } val target = reused ?: PromptAnvil() - kept = initial.take(initial.length - initial.length % MAX_LENGTH) - field = initial.drop(kept.length) + field = initial.take(MAX_LENGTH) target.setItemStack(INPUT_SLOT, seed(field), false) anvil = target window = target.windowId.toInt() @@ -128,7 +126,7 @@ internal class MinestomAnvilKeyboard( is ClientNameItemPacket -> { event.isCancelled = true - if (awaiting == null) edit(current, packet.itemName()) + if (awaiting == null) edit(packet.itemName()) } is ClientClickWindowPacket -> { @@ -148,22 +146,11 @@ internal class MinestomAnvilKeyboard( } } - private fun edit( - current: PromptAnvil, - name: String, - ) { - val typed = name.filter { it >= ' ' && it != '\u007f' && it != '§' } - if (typed.length >= MAX_LENGTH) { - kept += typed.take(MAX_LENGTH) - field = "" - current.setItemStack(INPUT_SLOT, seed(field), false) - synced(seeding = true) { sendSlots(current) } - } else if (typed != field) { - field = typed - } else { - return - } - events.onEdit(kept + field) + private fun edit(name: String) { + val typed = name.filter { it >= ' ' && it != '\u007f' && it != '§' }.take(MAX_LENGTH) + if (typed == field) return + field = typed + events.onEdit(field) } /** Ends the edit with its text; with [closing], the anvil is still open on the client, so the server closes it. */ @@ -171,7 +158,7 @@ internal class MinestomAnvilKeyboard( current: PromptAnvil, closing: Boolean, ) { - val text = kept + field + val text = field reset() synced(seeding = false) { if (closing && player.openInventory === current) player.closeInventory() diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index fe3247ff..8aa1cfea 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -459,7 +459,15 @@ internal class MinestomFullscreen private constructor( } is OpenWindowPacket -> { - if (textPrompt.ownsOpen(packet.windowId())) return + if (textPrompt.ownsOpen(packet.windowId())) { + // The anvil replaces any open container without a close packet. + if (containerOpen) { + containerWindowId = NO_CONTAINER + containerGeneration++ + hideTime(realTime().clocks()) + } + return + } containerWindowId = packet.windowId() containerGeneration++ // The container replaces the client's dialog without any answer. diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt index b19f4940..e7092248 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt @@ -27,8 +27,8 @@ import java.util.concurrent.atomic.AtomicLong * The dialog has a text field over a Confirm and a Cancel button, which report through custom click actions whose ids * are unique to the prompt, so responses to any other dialog are ignored. The client closes the dialog when a button * is pressed, and Escape does nothing. The buttons sit in the dialog's body, clear of the footer, which falls inside - * the strip the pack's shader clips while the HUD is hidden. The keyboard ends with its text when the player clicks or - * presses Escape. + * the strip the pack's shader clips while the HUD is hidden. The keyboard holds at most 50 characters and ends with its + * text when the player presses Escape; a click on a slot or outside the anvil may end it too. * * Either kind also ends with `null` when replaced, when a server container takes over (on the next tick), or with * [cancel]. Each end reports to its callback once, after the prompt has left [current], so a callback may open the diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index e252b869..d85b4eca 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -40,6 +40,7 @@ import net.minestom.server.entity.PlayerHand import net.minestom.server.entity.attribute.Attribute import net.minestom.server.event.EventDispatcher import net.minestom.server.event.EventListener +import net.minestom.server.event.inventory.InventoryOpenEvent import net.minestom.server.event.player.PlayerCustomClickEvent import net.minestom.server.event.player.PlayerDisconnectEvent import net.minestom.server.event.player.PlayerPacketEvent @@ -371,7 +372,7 @@ class MinestomFullscreenTest : menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) } - "a click anywhere in the anvil's window, even outside it, finishes with the text and closes the anvil" { + "a click outside the anvil finishes with the text and closes the anvil" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() @@ -467,38 +468,70 @@ class MinestomFullscreenTest : menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) } - "a full field continues in a fresh one, keeping its text, and drops renames until the fresh one settles" { + "the keyboard holds 50 characters: a longer initial text is cut, and a longer rename is truncated" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() - val start = "x".repeat(55) - menu.type(start) + menu.type("x".repeat(55)) val window = fixture.window() fixture .sent() .last() .items()[0] .get(DataComponents.CUSTOM_NAME) shouldBe - Component.text("xxxxx") + Component.text("x".repeat(50)) fixture.pong() + + fixture.receive(ClientNameItemPacket("x".repeat(50) + "y")) + menu.edits shouldBe emptyList() + fixture.receive(ClientNameItemPacket("z".repeat(60))) + fixture.closeWindow(window) + + menu.edits shouldContainExactly listOf("z".repeat(50)) + menu.prompts shouldContainExactly listOf("z".repeat(50)) + } + + "a refused anvil open resolves the prompt with null and leaves the band hidden" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + menu.open() + player.eventNode().addListener(InventoryOpenEvent::class.java) { it.isCancelled = true } fixture.clear() - val full = "xxxxx" + "y".repeat(45) - fixture.receive(ClientNameItemPacket(full)) - menu.edits shouldContainExactly listOf("x".repeat(50) + full) - fixture - .sent() - .last() - .items()[0] - .get(DataComponents.CUSTOM_NAME) shouldBe - Component.text("") - fixture.receive(ClientNameItemPacket("lost")) - fixture.pong() - fixture.receive(ClientNameItemPacket("z")) + menu.type("hi") + + menu.prompts shouldContainExactly listOf(null) + player.openInventory shouldBe null + fixture.sent().last().gameTime() shouldBe HIDDEN_AGE + } + + "a keyboard replacing an open container takes over its band, and finishing restores the hidden band and slots" { + val fixture = FullscreenFixture() + val player = fixture.player + val diamond = ItemStack.of(Material.DIAMOND) + player.inventory.setItemStack(2, diamond) + val menu = Menu(player) + menu.open() + player.openInventory(Inventory(InventoryType.CHEST_1_ROW, Component.empty())) + fixture.clear() + fixture.hotbar()[2] shouldBe Material.DIAMOND + + menu.type("hi") + fixture.sent().last().gameTime() shouldBe PROMPT_AGE + val window = fixture.window() + + fixture.clear() fixture.closeWindow(window) + menu.prompts shouldContainExactly listOf("hi") + fixture.sent().last().gameTime() shouldBe HIDDEN_AGE + fixture.hotbar() shouldBe List(9) { Material.PAPER } - menu.edits.last() shouldBe "x".repeat(50) + full + "z" - menu.prompts shouldContainExactly listOf("x".repeat(50) + full + "z") + fixture.clear() + fixture.send(SetTimePacket(100, emptyMap())) + fixture.sent().map { it.gameTime() } shouldContainExactly listOf(100L, HIDDEN_AGE) + fixture.send(SetPlayerInventorySlotPacket(2, diamond)) + fixture.hotbar() shouldBe List(9) { Material.PAPER } } "sneaking leaves the window and restores the player" { diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index b721b823..8f604e5c 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -94,15 +94,15 @@ public abstract class FullscreenView( /** * Lets the player type on the canvas, starting from [initial]: the keyboard prompt. Nothing of the prompt is drawn, * so draw the text from [onEdit], which receives the whole text after each edit and re-renders at once rather than - * on the next tick. [onDone] then receives the text once, when the player presses Escape or clicks anywhere. There - * is no cancel; use [promptTextDialog] when the player must be able to back out. While the prompt is open the HUD + * on the next tick. [onDone] then receives the text once, when the player presses Escape; a click on a slot or + * outside the anvil may finish it too, but a click on the rename field does not. There is no cancel; use [promptTextDialog] when the player must be able to back out. While the prompt is open the HUD * stays hidden, canvas input is ignored, so the cursor stays where it was, and the hovered control's tooltip is not * drawn. * * The prompt is an anvil the pack's shaders hide, so: * - the server never learns the client's caret, so a drawn caret belongs at the end of the text; - * - the anvil holds 50 characters, so the text moves on in 50-character pieces: the earlier pieces cannot be edited, - * and characters typed while the next piece opens can be lost; + * - the anvil holds at most 50 characters, so [initial] is cut to 50 and typing stops there ([promptTextDialog] + * allows 256); * - GUI text and fills in the anvil's rename field and label bands, such as chat lines crossing them, are hidden * while it is open; * - the system mouse pointer shows; diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index 66c85979..3c6a83aa 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -49,8 +49,9 @@ public interface OpenFullscreen { * Captures text the player types, starting from [initial], while the canvas stays in view; the host draws nothing * of the prompt, so the window draws the text itself. Reports the whole text to [onEdit] after each edit the client * sends, and the outcome to [onDone] exactly once, on the player's thread: the text when the player presses Escape - * or clicks anywhere, and `null` when another prompt replaces this one, a server container opens over it, or the - * window ends. There is no cancel. The HUD stays hidden while the prompt is open. + * (a click on a slot or outside the keyboard may finish it too), and `null` when another prompt replaces this one, + * a server container opens over it, or the window ends. There is no cancel. The text holds at most 50 characters, + * and [initial] is cut to fit. The HUD stays hidden while the prompt is open. * * Both kinds of prompt share one slot: opening either replaces an open prompt of either kind. The prompt has left * [promptOpen] by the time [onDone] runs, so [onDone] may open the next prompt. On a closed window, or when the host From 4d8db3ae5f5fbea3f884fa488e5871bfec347759 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:46:10 -0400 Subject: [PATCH 7/8] fix(runtime): ignore stale container clicks while the keyboard prompt is open --- README.md | 7 +++---- .../minestom/internal/MinestomAnvilKeyboard.kt | 6 +++++- .../window/minestom/MinestomFullscreenTest.kt | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a70c0fd2..bd0a95a5 100644 --- a/README.md +++ b/README.md @@ -264,10 +264,9 @@ Limitations: either way, more than 4 notches in one tick read as a scroll the other way, and number keys look like scrolling. - Chat and other GUI overlays draw over the canvas, and there are no real items. - The keyboard prompt is experimental. The server never sees the caret, so draw it at the end of the text. The anvil - holds 50 characters, so longer text continues in a fresh field: text in earlier fields can no longer be edited, and - keys typed just as the fresh field opens can be lost. While it is open, GUI text and fills in the anvil's rename field - and label bands, such as chat lines crossing them, are hidden, and the system mouse pointer shows. Each edit takes a - round trip to reach `onEdit`. Without the pack's shaders, the player sees a plain vanilla anvil. + holds at most 50 characters. While it is open, GUI text and fills in the anvil's rename field and label bands, such as + chat lines crossing them, are hidden, and the system mouse pointer shows. Each edit takes a round trip to reach + `onEdit`. Without the pack's shaders, the player sees a plain vanilla anvil. ## Supported versions diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt index d2184675..914f1c3d 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomAnvilKeyboard.kt @@ -130,7 +130,11 @@ internal class MinestomAnvilKeyboard( } is ClientClickWindowPacket -> { - if (packet.windowId() != window) return + // Minestom routes any nonzero window id to the open inventory, so a stale id would act on the anvil. + if (packet.windowId() != window) { + if (packet.windowId() != 0) event.isCancelled = true + return + } event.isCancelled = true finish(current, closing = true) } diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index d85b4eca..e5ec499e 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -19,6 +19,7 @@ import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.ints.shouldBeLessThan import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import net.kyori.adventure.key.Key import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.Component @@ -388,6 +389,19 @@ class MinestomFullscreenTest : fixture.sent().single().gameTime() shouldBe HIDDEN_AGE } + "a click carrying a stale window id is cancelled and leaves the prompt open" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.type("hi") + val window = fixture.window() + + fixture.clickWindow(window + 1, 3).isCancelled shouldBe true + + menu.prompts shouldBe emptyList() + fixture.player.openInventory shouldNotBe null + } + "a prompt replacing one of the other kind takes it off the client first; a keyboard keeps its anvil" { val fixture = FullscreenFixture() val player = fixture.player From 40a48b15d0dfa580ccf0cf640ca0a8dea090a2c0 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:55:02 -0400 Subject: [PATCH 8/8] fix(core): keep clouds still when the keyboard prompt opens Clouds scroll with the world age, so the 12000-tick jump between the HUD and prompt bands moved them. The prompt band now sits a whole number of cloud periods above the HUD band. Co-Authored-By: Claude Opus 5.5 --- crates/window-core/src/fullscreen.rs | 10 ++++++---- .../example/generated/WindowFullscreenEntries.kt | 2 +- .../window/minestom/MinestomFullscreenTest.kt | 4 ++-- .../kotlin/com/chunkzero/window/FullscreenManifests.kt | 2 +- plugin/test/golden/fullscreen/expected.sha256 | 4 ++-- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/window-core/src/fullscreen.rs b/crates/window-core/src/fullscreen.rs index b57ac665..2f52164a 100644 --- a/crates/window-core/src/fullscreen.rs +++ b/crates/window-core/src/fullscreen.rs @@ -99,10 +99,12 @@ pub const MAX_COLORS: usize = 1 << 10; /// 24000. pub const HUD_HIDDEN_WORLD_AGE: i64 = -1_000_000_001; -/// The world age the server sends while the keyboard text prompt's anvil is open: 12000 ticks above -/// [`HUD_HIDDEN_WORLD_AGE`], so `GameTime` is about -0.17 rather than -0.67. Both bands hide the HUD, and the prompt -/// band also hides what the anvil draws. -pub const PROMPT_WORLD_AGE: i64 = HUD_HIDDEN_WORLD_AGE + 12_000; +/// The world age the server sends while the keyboard text prompt's anvil is open: 204800 ticks above +/// [`HUD_HIDDEN_WORLD_AGE`], so `GameTime` is about -0.13 rather than -0.67. Both bands hide the HUD, and the prompt +/// band also hides what the anvil draws. Clouds scroll by the world age modulo 400 times the cloud texture's width, +/// and the gap is a multiple of that for widths up to 512 (vanilla's is 256), so the clouds hold still when the anvil +/// opens or closes. +pub const PROMPT_WORLD_AGE: i64 = HUD_HIDDEN_WORLD_AGE + 204_800; /// The `GameTime` between the HUD band, below it, and the prompt band, above it. pub const PROMPT_TIME_SPLIT: f64 = -0.4; diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt index b92dc83f..009ad957 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowFullscreenEntries.kt @@ -706,7 +706,7 @@ internal object WindowFullscreenEntries { backdrop = "\uE6AF\uDB80\uDC07\uDB80\uDC0A", overlayMarker = "#a3f800", hudHiddenWorldAge = -1000000001L, - promptWorldAge = -999988001L, + promptWorldAge = -999795201L, hudStripHeight = 56, hudStripHalfWidth = 96, hudMaxHealth = 20, diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index e5ec499e..4bfabe68 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -871,7 +871,7 @@ class MinestomFullscreenTest : }) { private companion object { const val HIDDEN_AGE = -1_000_000_001L - const val PROMPT_AGE = -999_988_001L + const val PROMPT_AGE = -999_795_201L } } @@ -1073,7 +1073,7 @@ private fun manifest(mode: FullscreenMode): WindowManifest { backdrop = "B", overlayMarker = "#a3f800", hudHiddenWorldAge = -1_000_000_001, - promptWorldAge = -999_988_001, + promptWorldAge = -999_795_201, hudStripHeight = 56, hudStripHalfWidth = 96, hudMaxHealth = 20, diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt index 179ede8b..5597a3c6 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt @@ -40,7 +40,7 @@ object FullscreenManifests { backdrop = "BACKDROP", overlayMarker = OVERLAY_MARKER, hudHiddenWorldAge = -1_000_000_001, - promptWorldAge = -999_988_001, + promptWorldAge = -999_795_201, hudStripHeight = 56, hudStripHalfWidth = 96, hudMaxHealth = 20, diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 554a2cc2..81408db2 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -35,7 +35,7 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -5611a7288a716852a43533923f19ffe054ccf870e7275b31371bead51bb76d8d dist/assets/window/window/debug.json +9d0099d4c382410fe21ad93245efc8a49ca696468c1440ea3f5b9113bc492708 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 kotlin-out/MenuFullscreen.kt @@ -43,7 +43,7 @@ f30b961fdca503fa68f87b140e31c8a42b48ad830a84c029fc7a7435760bfff9 kotlin-out/Win 2c40c9884ffe5ae1a29707d1195e2a15ad469816e166981e0be0b73b1e1a3696 kotlin-out/WindowEntries.kt 803c1f2387cf2cca16681dad3c4b38b360eefcda17be343491aca1ec552cc0ce kotlin-out/WindowFonts.kt bd87985f4223a45529a54d7453105bf2525188d337c8910cd4935344e0cec3bd kotlin-out/WindowFullscreenDefinitions.kt -07639a3683a2cb74299b1416ea3748055549caa445a58e572390498ffd26a1f3 kotlin-out/WindowFullscreenEntries.kt +b4ea4c076c99d76226dcff6c3b904315f898e867d624c52f93cefc43ffcb89fc kotlin-out/WindowFullscreenEntries.kt 7e12c0496b9c2f1e69bebd517cf396d3d178322561535b9b4521b9e5e2f4c644 kotlin-out/WindowHudDefinitions.kt f14312feddb64e8e65d2916d8e066c8323d35628c9da4e722e2f12f057d516e3 kotlin-out/WindowHudEntries.kt 9a1b53ef8c7c748afb02db43707298badd154a58dfc0ba7abada76b7a10d212f kotlin-out/WindowPackData.kt