From 36e3115f57573c5dce1f10cf2056c6badd7624c2 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:14:20 -0400 Subject: [PATCH 1/6] feat: draw authored art as a region's hover `hover` on a region and the industrial controls takes art as well as a boolean. Art that stretches covers the region; other art is drawn at its own size, centered on it. A hover glyph is now its art between two data rows that the text shader reads and discards, so the art keeps its own colors and alpha. The generated white outline uses the same layout, which limits a hover to 254 px tall. --- crates/window-core/src/authoring/art.rs | 46 ++++++ crates/window-core/src/authoring/element.rs | 9 +- .../src/authoring/element/build.rs | 13 +- crates/window-core/src/hud/mod.rs | 7 +- crates/window-core/src/hud/renderer/hover.rs | 30 ++-- crates/window-core/src/hud/tests.rs | 1 + crates/window-core/src/ir.rs | 19 ++- crates/window-core/src/layout/controls.rs | 3 +- crates/window-core/src/layout/visuals.rs | 53 +++++- crates/window-core/src/manifest.rs | 12 +- crates/window-core/src/model.rs | 2 +- crates/window-core/src/model/flex.rs | 16 +- crates/window-core/src/pipeline.rs | 9 +- crates/window-core/src/pipeline/hover.rs | 156 ++++++++++++------ crates/window-core/tests/hover.rs | 57 ++++++- docs/AUTHORING.md | 45 ++--- docs/MANIFEST.md | 6 +- .../window/manifest/InventoryEntries.kt | 12 +- plugin/src/theme/industrial/controls.tsx | 4 +- plugin/src/theme/industrial/face.ts | 4 +- plugin/src/theme/industrial/slots.tsx | 4 +- plugin/src/ui/components.ts | 8 +- plugin/src/ui/document.ts | 4 +- plugin/test/golden/hover/expected.sha256 | 18 +- plugin/test/golden/hover/ts/window/menu.tsx | 10 +- plugin/test/industrial.test.ts | 8 +- plugin/test/jsx.test.ts | 2 + plugin/test/jsx.typecheck.tsx | 6 +- 28 files changed, 414 insertions(+), 150 deletions(-) diff --git a/crates/window-core/src/authoring/art.rs b/crates/window-core/src/authoring/art.rs index 34232336..08ec7369 100644 --- a/crates/window-core/src/authoring/art.rs +++ b/crates/window-core/src/authoring/art.rs @@ -88,6 +88,52 @@ pub(super) fn intern(table: &mut Art, art: &mut Option, usage: ArtUse Ok(()) } +/// A region's `hover`: whether it gets the generated outline, or the art it draws instead. +#[derive(Clone, Debug)] +pub(super) enum HoverDto { + Outline(bool), + Art { + art: Option, + /// Whether the art stretches over the region as a frame, rather than being drawn at its own size. + stretch: bool, + }, +} + +impl<'de> Deserialize<'de> for HoverDto { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::Bool(outline) => Ok(Self::Outline(outline)), + value => { + let ArtRefDto::Inline(inline) = serde_json::from_value(value) + .map_err(|_| D::Error::custom("`hover` must be a boolean or an art value"))? + else { + unreachable!("art values deserialize inline"); + }; + // Texture art has a size of its own unless it is nine-sliced; shape art only when it sets one. + let stretch = inline.def.contains_key("insets") + || (inline.art == "shape" + && !inline.def.contains_key("width") + && !inline.def.contains_key("height")); + Ok(Self::Art { art: Some(ArtRefDto::Inline(inline)), stretch }) + } + } + } +} + +impl HoverDto { + /// Interns hover art into `table`, as a frame when it stretches and as an image otherwise. + pub(super) fn intern(&mut self, table: &mut Art) -> Result<()> { + match self { + Self::Outline(_) => Ok(()), + Self::Art { art, stretch } => intern(table, art, if *stretch { ArtUse::Frame } else { ArtUse::Image }), + } + } +} + fn intern_inline(table: &mut Art, inline: &InlineArtDto, usage: ArtUse) -> Result { let def = inline.def(usage)?; let kind = if usage == ArtUse::Frame { "frame" } else { "sprite" }; diff --git a/crates/window-core/src/authoring/element.rs b/crates/window-core/src/authoring/element.rs index 0d13e7ee..2f27f90e 100644 --- a/crates/window-core/src/authoring/element.rs +++ b/crates/window-core/src/authoring/element.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use serde::Deserialize; -use super::art::{ArtRefDto, ArtUse, intern}; +use super::art::{ArtRefDto, ArtUse, HoverDto, intern}; use super::flex::{FlexStyleDto, ItemLayoutDto}; use super::insets::InsetsDto; use super::parse::json_object_fields; @@ -46,7 +46,7 @@ pub(super) struct ElementDto { color: Option, tint: Option, tooltip: Option, - hover: Option, + hover: Option, shadow: bool, bold: bool, italic: bool, @@ -99,7 +99,7 @@ struct ElementShapeDto { color: Option, tint: Option, tooltip: Option, - hover: Option, + hover: Option, #[serde(default)] shadow: bool, #[serde(default)] @@ -204,6 +204,9 @@ pub(super) fn intern_art(children: &mut [ElementDto], table: &mut Art) -> Result intern(table, &mut child.track, ArtUse::Frame)?; intern(table, &mut child.art, ArtUse::Image)?; intern(table, &mut child.selected_sprite, ArtUse::Sprite)?; + if let Some(hover) = &mut child.hover { + hover.intern(table)?; + } intern_art(&mut child.children, table)?; } Ok(()) diff --git a/crates/window-core/src/authoring/element/build.rs b/crates/window-core/src/authoring/element/build.rs index 028acaa2..47b7e94a 100644 --- a/crates/window-core/src/authoring/element/build.rs +++ b/crates/window-core/src/authoring/element/build.rs @@ -1,9 +1,9 @@ use super::{ElementDto, convert_layout_children}; -use crate::authoring::art::ArtRefDto; +use crate::authoring::art::{ArtRefDto, HoverDto}; use crate::authoring::flex::parse_auto_flow; use crate::authoring::parse::validate_name; use crate::inventory::{InventorySlotSection, SlotRectClaim}; -use crate::model::{Bar, Element, FlexBox, Region, SlotSection, Switch, SwitchCase}; +use crate::model::{Bar, Element, FlexBox, Hover, Region, SlotSection, Switch, SwitchCase}; use crate::{Error, Result}; /// The widest and tallest a bar may be: one font atlas page, which its largest fill glyph must fit. @@ -200,10 +200,17 @@ impl ElementDto { } fn build_region(self) -> Result { + let hover = match &self.hover { + None => None, + Some(HoverDto::Outline(false)) => Some(Hover::None), + Some(HoverDto::Outline(true)) => Some(Hover::Outline), + Some(HoverDto::Art { art, stretch: true }) => art_name(art)?.map(Hover::Frame), + Some(HoverDto::Art { art, stretch: false }) => art_name(art)?.map(Hover::Image), + }; Ok(Element::Region(Box::new(Region { size: self.optional_size()?, tooltip: self.tooltip()?, - hover: self.hover, + hover, default_action: self.default_action, item_model: self.item_model, name: self.name, diff --git a/crates/window-core/src/hud/mod.rs b/crates/window-core/src/hud/mod.rs index ee14918a..b4441db9 100644 --- a/crates/window-core/src/hud/mod.rs +++ b/crates/window-core/src/hud/mod.rs @@ -30,11 +30,12 @@ pub struct ShaderOutput { pub warnings: Vec, } -/// Color of the corner pixels of a hover outline glyph, by which the text shader finds hover glyphs. +/// Color of the corner pixels of a hover glyph, by which the text shader finds hover glyphs. pub const HOVER_GLYPH_ID: Rgb = Rgb { r: 0x57, g: 0x48, b: 0x56 }; -/// Blue channel of the pixel beside each corner of a hover outline glyph, whose red and green hold the glyph's -/// width and height minus one. A glyph is a hover glyph only with both this and [`HOVER_GLYPH_ID`]. +/// Blue channel of the pixel beside each corner of a hover glyph, whose red and green hold the width and height +/// minus one of the art between the glyph's top and bottom rows. A glyph is a hover glyph only with both this and +/// [`HOVER_GLYPH_ID`]. pub const HOVER_SIZE_MARK: u8 = 0xa9; /// Emits core text shaders that relocate shader-placed HUDs, where `row_offsets` holds the rows each of `huds` is diff --git a/crates/window-core/src/hud/renderer/hover.rs b/crates/window-core/src/hud/renderer/hover.rs index e1e3714c..ef8dcde4 100644 --- a/crates/window-core/src/hud/renderer/hover.rs +++ b/crates/window-core/src/hud/renderer/hover.rs @@ -1,14 +1,15 @@ -//! Hover outline snippets of the pack_format 85-88 `core/text` shaders. +//! Hover snippets of the pack_format 85-88 `core/text` shaders. //! -//! A hover glyph rides in a tooltip title, so the client draws it exactly while its region is hovered. It is drawn -//! twice: the text copy, which the shader hides, and the shadow copy, which the shader moves onto the region. The -//! shadow color tells it where: +//! A hover glyph rides in a tooltip title, so the client draws it exactly while its region is hovered. The glyph is +//! the hover's art, in its own colors, between a top and a bottom row of data pixels that tell the shader the glyph +//! is a hover and how large its art is. It is drawn twice: the text copy, which the shader hides, and the shadow +//! copy, which the shader moves so its art is where the shadow color says: //! -//! - red and green: the region's left and top edge relative to the GUI center, plus 128; +//! - red and green: the art's left and top edge relative to the GUI center, plus 128; //! - blue and the top 3 bits of alpha: the width of the tooltip's text; //! - the low 5 bits of alpha: the tooltip's line count plus 1, at most 30, so alpha is never 0 or 255. //! -//! The fragment shader discards the outline inside the tooltip box those describe. +//! The fragment shader discards the data rows and the art inside the tooltip box those describe. use crate::hud::{HOVER_GLYPH_ID, HOVER_SIZE_MARK}; @@ -23,6 +24,7 @@ uniform sampler2D Sampler0; flat out int windowHover; flat out vec4 windowHoverCutout; +flat out vec2 windowHoverRows; out vec2 windowHoverPos; const ivec3 WINDOW_HOVER_ID = ivec3({id_r}, {id_g}, {id_b}); @@ -38,6 +40,7 @@ ivec3 window_hover_pixel(ivec2 pixel, int direction, int index) {{ void window_hover_apply() {{ windowHover = 0; windowHoverCutout = vec4(0.0); + windowHoverRows = vec2(0.0); windowHoverPos = vec2(0.0); vec2 texel = UV0 * vec2(textureSize(Sampler0, 0)); @@ -55,12 +58,16 @@ void window_hover_apply() {{ return; }} - vec2 size = vec2(sizePixel.rg) + 1.0; + // The glyph is its art between two data rows, which land above and below where the art is drawn. + vec2 artSize = vec2(sizePixel.rg) + 1.0; + vec2 size = artSize + vec2(0.0, 2.0); vec2 guiSize = ceil(window_hud_gui_size() - 0.001); - vec2 pos = floor(guiSize * 0.5) + vec2(color.rg) - 128.0 + corner * size; + vec2 art = floor(guiSize * 0.5) + vec2(color.rg) - 128.0; + vec2 pos = art + corner * size - vec2(0.0, 1.0); gl_Position = ProjMat * ModelViewMat * vec4(pos, Position.z, 1.0); vertexColor = vec4(1.0); windowHover = 1; + windowHoverRows = vec2(art.y, art.y + artSize.y); windowHoverPos = pos; int lines = (color.a & 31) - 1; @@ -84,16 +91,19 @@ pub(super) const HOVER_FRAGMENT_INPUTS: &str = " #ifdef IS_GUI flat in int windowHover; flat in vec4 windowHoverCutout; +flat in vec2 windowHoverRows; in vec2 windowHoverPos; #endif"; -/// Paints a hover glyph's pixels white, whatever data their colors hold, except inside its tooltip's box. +/// Discards a hover glyph's data rows, and its art inside its tooltip's box. pub(super) const HOVER_FRAGMENT_DRAW: &str = " #ifdef IS_GUI if (windowHover == 1) { + if (windowHoverPos.y < windowHoverRows.x || windowHoverPos.y > windowHoverRows.y) { + discard; + } if (all(greaterThan(windowHoverPos, windowHoverCutout.xy)) && all(lessThan(windowHoverPos, windowHoverCutout.zw))) { discard; } - texColor.rgb = vec3(1.0); } #endif"; diff --git a/crates/window-core/src/hud/tests.rs b/crates/window-core/src/hud/tests.rs index a4d9bcf3..ae379593 100644 --- a/crates/window-core/src/hud/tests.rs +++ b/crates/window-core/src/hud/tests.rs @@ -101,6 +101,7 @@ fn hover_outlines_join_the_define_variant_shaders_with_or_without_huds() { assert!(both.files[0].contents.contains("#endif\n#ifdef IS_GUI\n window_hover_apply();\n#endif\n}")); assert!(both.files[1].contents.contains("if (windowHudTextureMode != 1) {")); assert!(both.files[1].contents.contains("if (windowHover == 1) {")); + assert!(!both.files[1].contents.contains("texColor.rgb ="), "a hover glyph keeps its texture's colors"); // Without a shader-placed HUD there are no rule tables and no text background to relocate. let alone = emit(Some(88), &[], &[], true).unwrap(); diff --git a/crates/window-core/src/ir.rs b/crates/window-core/src/ir.rs index de82c5f2..808eac01 100644 --- a/crates/window-core/src/ir.rs +++ b/crates/window-core/src/ir.rs @@ -411,6 +411,21 @@ pub struct SpriteSlotIr { pub source: Option, } +/// What a region draws over itself while one of its slots is hovered. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HoverIr { + None, + /// The generated 1px white outline of the region's rect. + Outline, + /// Authored art. + Art { + /// The art's interned name. + name: String, + /// The art at its place in GUI space, which may extend past the region. + draw: Draw, + }, +} + /// An inventory region: slots it claims and fills with its hitbox item, and the action its clicks name. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RegionIr { @@ -428,8 +443,8 @@ pub struct RegionIr { pub default_action: Option, /// The hitbox item filling the region's slots; `None` leaves them empty. pub hitbox: Option, - /// The authored `hover`: whether the region is outlined while hovered. `None` outlines a region with an action. - pub hover: Option, + /// The authored `hover`: what the region draws while hovered. `None` outlines a region with an action. + pub hover: Option, /// The authored element this region comes from, such as ``region `buy` ``. pub source: String, } diff --git a/crates/window-core/src/layout/controls.rs b/crates/window-core/src/layout/controls.rs index 1ca1bb6e..947eeee5 100644 --- a/crates/window-core/src/layout/controls.rs +++ b/crates/window-core/src/layout/controls.rs @@ -69,6 +69,7 @@ impl Solver<'_, T> { None => format!("region `{name}`"), }; self.check_inside_bounds(rect, &source)?; + let hover = region.hover.as_ref().map(|hover| self.hover_ir(hover, rect, &source)).transpose()?; let hitbox = (region.tooltip.is_some() || region.item_model.is_some()) .then(|| Hitbox { item_model: region.item_model.clone(), tooltip: region.tooltip.clone() }); self.regions.push(RegionIr { @@ -79,7 +80,7 @@ impl Solver<'_, T> { unowned: false, default_action: region.default_action.clone(), hitbox, - hover: region.hover, + hover, source, }); Ok(()) diff --git a/crates/window-core/src/layout/visuals.rs b/crates/window-core/src/layout/visuals.rs index 4d196516..6654fd78 100644 --- a/crates/window-core/src/layout/visuals.rs +++ b/crates/window-core/src/layout/visuals.rs @@ -2,8 +2,8 @@ use super::Solver; use super::target::LayoutTarget; use crate::Result; use crate::geometry::{Point, Rect, Size}; -use crate::ir::{Align, ArtIr, Draw, Layer, SlotIr, SpriteSlotIr, TextureKey}; -use crate::model::{Bar, Frame, SpriteDef, TextFit, TextStyle, Tint}; +use crate::ir::{Align, ArtIr, Draw, HoverIr, Layer, SlotIr, SpriteSlotIr, TextureKey}; +use crate::model::{Bar, Frame, Hover, SpriteDef, TextFit, TextStyle, Tint}; use crate::pipeline::texture_source_path; impl Solver<'_, T> { @@ -67,16 +67,36 @@ impl Solver<'_, T> { let def = self.sprite_def(name)?; let rect = Rect::from_parts(origin, self.sprite_size(name, def)?); self.warn_if_overflow(rect, &format!("image `{name}`")); - let draw = match def { - SpriteDef::Texture { texture, .. } => { - Draw::Sprite { texture: TextureKey(texture_source_path(texture)), dest: rect } - } - SpriteDef::Generated { style, .. } => Draw::Generated { style: style.clone(), dest: rect }, - }; - self.emit_draw(draw, tint); + self.emit_draw(sprite_draw(def, rect), tint); Ok(rect) } + /// What `hover` draws over a region at `rect`. Art need not stay inside the pane: the client draws it over the + /// whole screen. + pub(super) fn hover_ir(&mut self, hover: &Hover, rect: Rect, referenced_by: &str) -> Result { + Ok(match hover { + Hover::None => HoverIr::None, + Hover::Outline => HoverIr::Outline, + Hover::Frame(name) => { + let project = self.project; + let def = project + .art + .frames + .get(name) + .ok_or_else(|| self.target.layout_err(format!("unknown frame `{name}`")))?; + HoverIr::Art { name: name.clone(), draw: self.frame_def_draw(name, def, rect, referenced_by)? } + } + Hover::Image(name) => { + let def = self.sprite_def(name)?; + let size = self.sprite_size(name, def)?; + // An odd difference in size leaves the art half a pixel off center, toward the top-left. + let x = rect.x + (rect.width as i32 - size.width as i32).div_euclid(2); + let y = rect.y + (rect.height as i32 - size.height as i32).div_euclid(2); + HoverIr::Art { name: name.clone(), draw: sprite_draw(def, Rect::new(x, y, size.width, size.height)) } + } + }) + } + /// Adds `draw` to the pane or switch case being emitted. /// /// Art tinted from a palette becomes an art layer, drawn at its authored place among the runtime layers. Untinted @@ -155,6 +175,11 @@ impl Solver<'_, T> { let def = project.art.frames.get(frame).ok_or_else(|| self.target.layout_err(format!("unknown frame `{frame}`")))?; self.warn_if_overflow(dest, referenced_by); + self.frame_def_draw(frame, def, dest, referenced_by) + } + + /// The frame `def`, named `frame`, stretched over `dest`, after checking the frame. + fn frame_def_draw(&self, frame: &str, def: &Frame, dest: Rect, referenced_by: &str) -> Result { Ok(match def { Frame::Texture { texture, insets } => { let tex_size = self.intrinsic_texture_size(texture, referenced_by)?; @@ -170,6 +195,16 @@ impl Solver<'_, T> { } } +/// The sprite `def` drawn whole at `rect`. +fn sprite_draw(def: &SpriteDef, rect: Rect) -> Draw { + match def { + SpriteDef::Texture { texture, .. } => { + Draw::Sprite { texture: TextureKey(texture_source_path(texture)), dest: rect } + } + SpriteDef::Generated { style, .. } => Draw::Generated { style: style.clone(), dest: rect }, + } +} + fn text_slot_ir(name: String, text: Option, rect: Rect, style: &TextStyle, fit: TextFit) -> SlotIr { SlotIr { name, diff --git a/crates/window-core/src/manifest.rs b/crates/window-core/src/manifest.rs index 89f412d9..94a9847b 100644 --- a/crates/window-core/src/manifest.rs +++ b/crates/window-core/src/manifest.rs @@ -439,7 +439,7 @@ pub struct RegionEntry { /// The hitbox item filling [`Self::filled_slots`]; absent leaves them empty unless [`Self::hover`] is present. #[serde(default, skip_serializing_if = "Option::is_none")] pub hitbox: Option, - /// The outline drawn around the region while one of its filled slots is hovered. Present only with + /// The outline or art drawn over the region while one of its filled slots is hovered. Present only with /// `coreShaders.hover`. #[serde(default, skip_serializing_if = "Option::is_none")] pub hover: Option, @@ -448,15 +448,15 @@ pub struct RegionEntry { pub source: Option, } -/// A region's hover outline: what the runtime adds to its hitbox item's tooltip title, which the core text shader -/// then moves onto the region. +/// A region's hover, an outline or art: what the runtime adds to its hitbox item's tooltip title, which the core +/// text shader then moves onto the region. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct HoverEntry { - /// The outline glyph and the spacers returning the cursor to its start, in [`Manifest::font`]. + /// The hover glyph and the spacers returning the cursor to its start, in [`Manifest::font`]. pub text: String, - /// The region's left edge relative to the GUI center, in GUI pixels, within `-128..=127`. + /// The left edge of what is drawn relative to the GUI center, in GUI pixels, within `-128..=127`. pub x: i32, - /// The region's top edge relative to the GUI center, in GUI pixels, within `-128..=127`. + /// The top edge of what is drawn relative to the GUI center, in GUI pixels, within `-128..=127`. pub y: i32, } diff --git a/crates/window-core/src/model.rs b/crates/window-core/src/model.rs index c6b4defa..7a6022c7 100644 --- a/crates/window-core/src/model.rs +++ b/crates/window-core/src/model.rs @@ -11,7 +11,7 @@ use crate::surface::ContainerKind; mod flex; mod generated; -pub use flex::{FlexBox, ItemLayout, LayoutChild, Region, SlotSection, Switch, SwitchCase}; +pub use flex::{FlexBox, Hover, ItemLayout, LayoutChild, Region, SlotSection, Switch, SwitchCase}; pub use generated::{GeneratedKind, GeneratedStyle}; /// The art a project draws, by interned name: inline art values and the runtime sprite catalog. diff --git a/crates/window-core/src/model/flex.rs b/crates/window-core/src/model/flex.rs index 751ca350..e838480e 100644 --- a/crates/window-core/src/model/flex.rs +++ b/crates/window-core/src/model/flex.rs @@ -68,14 +68,26 @@ pub struct Region { /// Fixed size; `None` fills the parent box. pub size: Option, pub tooltip: Option, - /// Whether it is outlined while hovered; `None` outlines a region that takes clicks. Needs `coreShaders.hover`. - pub hover: Option, + /// What it draws while hovered; `None` outlines a region that takes clicks. Needs `coreShaders.hover`. + pub hover: Option, /// Item model of the hitbox item filling its slots. pub item_model: Option, /// Authored name shown in errors and as the region's diagnostic source. pub debug_name: Option, } +/// What a [`Region`] draws over itself while one of its slots is hovered. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Hover { + None, + /// The generated 1px white outline of its rect. + Outline, + /// The named frame stretched over its rect. + Frame(String), + /// The named sprite at its own size, centered on its rect. + Image(String), +} + /// One case of a [`Switch`]: a box that fills the switch. #[derive(Clone, Debug)] pub struct SwitchCase { diff --git a/crates/window-core/src/pipeline.rs b/crates/window-core/src/pipeline.rs index 5c31f8e9..31671503 100644 --- a/crates/window-core/src/pipeline.rs +++ b/crates/window-core/src/pipeline.rs @@ -197,7 +197,7 @@ fn compile_layouts( } let hover_keys = hover.then(|| hover::glyph_keys(&windows)).into_iter().flatten(); let codepoints = glyphs::allocate_codepoints(&windows, &huds, &composites, used_sprites, hover_keys)?; - let mut ctx = CompileContext::new(namespace, runtime_sprites, text_fonts, codepoints); + let mut ctx = CompileContext::new(namespace, textures, runtime_sprites, text_fonts, codepoints); ctx.hover = hover; ctx.warnings.extend(field_warnings); let has_anvil_input = windows.iter().any(|w| !w.inputs.is_empty()); @@ -258,6 +258,7 @@ fn validate_namespace(namespace: &str) -> Result<()> { /// provider order. struct CompileContext<'a> { namespace: &'a str, + textures: &'a BTreeMap, runtime_sprites: &'a BTreeMap, text_fonts: &'a TextFonts, codepoints: BTreeMap, @@ -269,21 +270,23 @@ struct CompileContext<'a> { text_font_offsets: BTreeSet<(&'a str, i32)>, /// The catalog sprites each sprite font, by vertical offset, can show. sprite_fonts: BTreeMap>, - /// `coreShaders.hover`: whether regions get hover outlines. + /// `coreShaders.hover`: whether regions draw hovers. hover: bool, - /// The hover outline glyphs emitted so far. + /// The hover glyphs emitted so far. hover_glyphs: BTreeSet, } impl<'a> CompileContext<'a> { fn new( namespace: &'a str, + textures: &'a BTreeMap, runtime_sprites: &'a BTreeMap, text_fonts: &'a TextFonts, codepoints: BTreeMap, ) -> Self { Self { namespace, + textures, runtime_sprites, text_fonts, codepoints, diff --git a/crates/window-core/src/pipeline/hover.rs b/crates/window-core/src/pipeline/hover.rs index b3a1281d..6984a4a0 100644 --- a/crates/window-core/src/pipeline/hover.rs +++ b/crates/window-core/src/pipeline/hover.rs @@ -1,16 +1,17 @@ -//! Hover outlines (`coreShaders.hover`) and the vanilla slot highlight (`vanilla.slotHighlight`). +//! Region hovers (`coreShaders.hover`) and the vanilla slot highlight (`vanilla.slotHighlight`). //! -//! An outlined region carries a hover glyph the runtime puts in its hitbox item's tooltip title, so the client draws -//! the glyph exactly while a slot of the region is hovered. The core text shader then moves it onto the region. +//! A region that draws an outline or art while hovered carries a hover glyph the runtime puts in its hitbox item's +//! tooltip title, so the client draws the glyph exactly while a slot of the region is hovered. The core text shader +//! then moves it onto the region. use std::collections::{BTreeMap, BTreeSet}; use crate::authoring::Replacement; -use crate::compose::Texture; +use crate::compose::{Texture, compose_draws}; use crate::font::{bitmap_provider, spacers_for}; -use crate::geometry::Size; +use crate::geometry::{Rect, Size}; use crate::hud::{HOVER_GLYPH_ID, HOVER_SIZE_MARK}; -use crate::ir::{LaidOutWindow, RegionIr}; +use crate::ir::{Draw, HoverIr, LaidOutWindow, RegionIr}; use crate::manifest::HoverEntry; use crate::size::Part; use crate::{Error, Result}; @@ -29,9 +30,12 @@ const SLOT_HIGHLIGHT_SIZE: Size = Size { width: 24, height: 24 }; const SLOT_HIGHLIGHT_BACK: &str = "assets/minecraft/textures/gui/sprites/container/slot_highlight_back.png"; const SLOT_HIGHLIGHT_FRONT: &str = "assets/minecraft/textures/gui/sprites/container/slot_highlight_front.png"; -/// The narrowest outline whose rows hold a corner and a size pixel at each end. +/// The narrowest hover glyph whose data rows hold an id and a size pixel at each end. const MIN_WIDTH: u32 = 4; +/// The tallest hover art: its glyph adds a data row above and below it. +const MAX_HEIGHT: u32 = MAX_GLYPH_SIZE - 2; + /// Files for `vanilla.slotHighlight`: a sprite replaces the front highlight and the back one is blanked. pub(super) fn slot_highlight_files( runtime_sprites: &BTreeMap, @@ -75,41 +79,64 @@ pub(super) fn check_disabled(windows: &[&LaidOutWindow]) -> Result<()> { Ok(()) } -/// Whether `region` is outlined while hovered: as it says, else when it takes clicks. -fn outlined(region: &RegionIr) -> bool { - region.hover.unwrap_or(region.action.is_some()) +/// What a hovered region draws: authored art, or else the generated outline. +struct Shown<'a> { + /// Where it is drawn, in GUI space. + rect: Rect, + /// The art's interned name and its draw over `rect`. + art: Option<(&'a str, &'a Draw)>, } -/// The codepoint allocation key of each distinct outline size among `windows`. +impl Shown<'_> { + /// What `region` draws while hovered: as it says, else the outline when it takes clicks. + fn of(region: &RegionIr) -> Option> { + match ®ion.hover { + Some(HoverIr::None) => None, + Some(HoverIr::Art { name, draw }) => Some(Shown { rect: *draw.dest(), art: Some((name, draw)) }), + Some(HoverIr::Outline) => Some(Shown { rect: region.rect, art: None }), + None => region.action.is_some().then_some(Shown { rect: region.rect, art: None }), + } + } + + /// The codepoint allocation key of its glyph, which regions drawing the same pixels share. + fn glyph_key(&self) -> String { + let Size { width, height } = self.rect.size(); + match self.art { + Some((name, _)) => format!("hover/{name}/{width}x{height}"), + None => format!("hover/{width}x{height}"), + } + } +} + +/// The codepoint allocation key of each distinct hover glyph among `windows`. pub(super) fn glyph_keys(windows: &[&LaidOutWindow]) -> impl Iterator { windows .iter() .flat_map(|w| &w.regions) - .filter(|region| outlined(region)) - .map(|region| glyph_key(region.rect.size())) + .filter_map(Shown::of) + .map(|shown| shown.glyph_key()) .collect::>() .into_iter() } -fn glyph_key(size: Size) -> String { - format!("hover/{}x{}", size.width, size.height) -} - impl CompileContext<'_> { - /// The hover outline of `region` of `w`, emitting its glyph the first time its size is seen; `None` when the - /// region is not outlined or `coreShaders.hover` is off. + /// The hover of `region` of `w`, emitting its glyph the first time it is seen; `None` when the region draws + /// nothing while hovered or `coreShaders.hover` is off. pub(super) fn hover_entry(&mut self, w: &LaidOutWindow, region: &RegionIr) -> Result> { - if !self.hover || !outlined(region) { + let Some(shown) = Shown::of(region).filter(|_| self.hover) else { return Ok(None); - } - let (rect, size) = (region.rect, region.rect.size()); - let err = |message: String| { - Error::Validation(format!("window `{}`: {} {message}; set `hover` to false on it", w.name, region.source)) }; - if !(MIN_WIDTH..=MAX_GLYPH_SIZE).contains(&size.width) || !(1..=MAX_GLYPH_SIZE).contains(&size.height) { + let (rect, size) = (shown.rect, shown.rect.size()); + let (what, fix) = match shown.art { + Some(_) => ("hover art", "resize the art or set `hover` to false on it"), + None => ("hover outline", "set `hover` to false on it"), + }; + let err = + |message: String| Error::Validation(format!("window `{}`: {} {message}; {fix}", w.name, region.source)); + if !(MIN_WIDTH..=MAX_GLYPH_SIZE).contains(&size.width) || !(1..=MAX_HEIGHT).contains(&size.height) { return Err(err(format!( - "is {}x{} px, but a hover outline is {MIN_WIDTH} to {MAX_GLYPH_SIZE} px wide and 1 to \ - {MAX_GLYPH_SIZE} px tall", + "has a {}x{} px {what}, which must be {MIN_WIDTH} to {MAX_GLYPH_SIZE} px wide and 1 to {MAX_HEIGHT} \ + px tall", size.width, size.height ))); } @@ -117,25 +144,29 @@ impl CompileContext<'_> { let gui = w.surface.gui_size(); let (x, y) = (rect.x - (gui.width / 2) as i32, rect.y - (gui.height / 2) as i32); if !(-128..=127).contains(&x) || !(-128..=127).contains(&y) { - return Err(err("starts more than 128 px from the GUI center, too far for a hover outline".into())); + return Err(err(format!("has a {what} that starts more than 128 px from the GUI center"))); } - let key = glyph_key(size); + let key = shown.glyph_key(); let glyph = char::from_u32(self.codepoints[&key]).expect("hover codepoint is a valid char"); if self.hover_glyphs.insert(glyph) { - let texture = outline_texture(size); + let art = match shown.art { + Some((_, draw)) => compose_draws(std::slice::from_ref(draw), &w.name, self.textures)?.texture, + None => outline_texture(size), + }; + let texture = glyph_texture(&art); // The text shader assumes ascent 0 when it finds the tooltip from the glyph's position. - self.bitmap_providers.push(bitmap_provider(self.namespace, &key, size.height, 0, glyph)?); + self.bitmap_providers.push(bitmap_provider(self.namespace, &key, texture.height, 0, glyph)?); let path = format!("assets/{}/textures/font/{key}.png", self.namespace); self.push(Part::Hover, OutputFile::binary(path, texture.encode_png()?)); } - // A bitmap glyph advances by its width plus 1. + // A bitmap glyph advances by its width plus 1; the data rows are opaque at both ends. let text = format!("{glyph}{}", spacers_for(-(size.width as i32 + 1))); Ok(Some(HoverEntry { text, x, y })) } - /// Emits the transparent sprites of the tooltip style `:hover`, which hides the tooltip box of an - /// outlined region without a tooltip. + /// Emits the transparent sprites of the tooltip style `:hover`, which hides the tooltip box of a + /// region that draws a hover without a tooltip. pub(super) fn emit_hover_tooltip_style(&mut self) -> Result<()> { for sprite in ["background", "frame"] { let path = format!("assets/{}/textures/gui/sprites/tooltip/hover_{sprite}.png", self.namespace); @@ -145,30 +176,34 @@ impl CompileContext<'_> { } } -/// The 1px outline of a `size` region. The text shader paints every opaque pixel white, so their colors hold data: -/// each corner is [`HOVER_GLYPH_ID`], and the pixel beside it in its row holds the size minus one in red and green -/// and [`HOVER_SIZE_MARK`] in blue. Every vertex of the glyph's quad reads both from its own corner. +/// The 1px white outline of a `size` region. fn outline_texture(size: Size) -> Texture { let Size { width, height } = size; let mut texture = Texture::transparent(width, height); - let mut set = |x: u32, y: u32, rgb: [u8; 3]| { - let i = ((y * width + x) * 4) as usize; - texture.rgba[i..i + 4].copy_from_slice(&[rgb[0], rgb[1], rgb[2], 0xff]); - }; for y in 0..height { for x in 0..width { if x == 0 || x == width - 1 || y == 0 || y == height - 1 { - set(x, y, [0xff; 3]); + texture.blend(x, y, [0xff; 4]); } } } - let id = [HOVER_GLYPH_ID.r, HOVER_GLYPH_ID.g, HOVER_GLYPH_ID.b]; - let size_pixel = [(width - 1) as u8, (height - 1) as u8, HOVER_SIZE_MARK]; - for y in [0, height - 1] { - set(0, y, id); - set(1, y, size_pixel); - set(width - 2, y, size_pixel); - set(width - 1, y, id); + texture +} + +/// The hover glyph of `art`: the art between two data rows, which the text shader reads and does not draw. Each end +/// of a data row is [`HOVER_GLYPH_ID`], and the pixel beside it holds the art's size minus one in red and green and +/// [`HOVER_SIZE_MARK`] in blue. Every vertex of the glyph's quad reads both from its own corner. +fn glyph_texture(art: &Texture) -> Texture { + let (width, height) = (art.width, art.height); + let mut texture = Texture::transparent(width, height + 2); + let row = width as usize * 4; + texture.rgba[row..row + art.rgba.len()].copy_from_slice(&art.rgba); + let id = [HOVER_GLYPH_ID.r, HOVER_GLYPH_ID.g, HOVER_GLYPH_ID.b, 0xff]; + let size_pixel = [(width - 1) as u8, (height - 1) as u8, HOVER_SIZE_MARK, 0xff]; + for y in [0, height + 1] { + for (x, pixel) in [(0, id), (1, size_pixel), (width - 2, size_pixel), (width - 1, id)] { + texture.blend(x, y, pixel); + } } texture } @@ -178,16 +213,31 @@ mod tests { use super::*; #[test] - fn outline_texture_holds_the_id_and_size_at_every_corner() { - let texture = outline_texture(Size { width: 6, height: 3 }); + fn glyph_texture_holds_the_id_and_size_in_rows_outside_the_art() { + let mut art = Texture::transparent(6, 3); + art.blend(0, 0, [1, 2, 3, 4]); + art.blend(5, 2, [5, 6, 7, 8]); + let texture = glyph_texture(&art); + assert_eq!((texture.width, texture.height), (6, 5)); let id = [HOVER_GLYPH_ID.r, HOVER_GLYPH_ID.g, HOVER_GLYPH_ID.b, 0xff]; - for y in [0, 2] { + for y in [0, 4] { assert_eq!(texture.get(0, y), id); assert_eq!(texture.get(5, y), id); assert_eq!(texture.get(1, y), [5, 2, HOVER_SIZE_MARK, 0xff]); assert_eq!(texture.get(4, y), [5, 2, HOVER_SIZE_MARK, 0xff]); + assert_eq!(texture.get(2, y)[3], 0, "the rest of a data row is transparent"); } - assert_eq!(texture.get(0, 1), [0xff; 4], "the edges between corners are opaque"); + assert_eq!(texture.get(0, 1), [1, 2, 3, 4], "the art keeps its colors and alpha"); + assert_eq!(texture.get(5, 3), [5, 6, 7, 8]); + assert_eq!(texture.get(1, 1)[3], 0); + } + + #[test] + fn outline_texture_is_a_white_border() { + let texture = outline_texture(Size { width: 6, height: 3 }); + assert_eq!(texture.get(0, 0), [0xff; 4]); + assert_eq!(texture.get(5, 1), [0xff; 4]); + assert_eq!(texture.get(3, 2), [0xff; 4]); assert_eq!(texture.get(2, 1)[3], 0, "the interior is transparent"); } } diff --git a/crates/window-core/tests/hover.rs b/crates/window-core/tests/hover.rs index 5c552e03..f6172b14 100644 --- a/crates/window-core/tests/hover.rs +++ b/crates/window-core/tests/hover.rs @@ -81,7 +81,7 @@ fn clickable_regions_are_outlined_unless_they_opt_out() { assert_eq!(shaders, ["assets/minecraft/shaders/core/text.fsh", "assets/minecraft/shaders/core/text.vsh"]); let font = output.files.iter().find(|file| file.path == "assets/window/font/ui.json").unwrap(); let font = String::from_utf8(font.contents.as_bytes().to_vec()).unwrap(); - assert!(font.contains(r#""file":"window:font/hover/16x16.png","height":16,"type":"bitmap""#), "{font}"); + assert!(font.contains(r#""file":"window:font/hover/16x16.png","height":18,"type":"bitmap""#), "{font}"); let kotlin = generate_kotlin(&output.manifest, "demo", KotlinTarget::Agnostic).unwrap(); let entries = kotlin.iter().find(|file| file.path == "WindowEntries.kt").unwrap(); @@ -113,7 +113,60 @@ fn hover_needs_its_core_shader_option_and_a_supported_pack_format() { { "type": "region", "on_click": click("thin"), "width": 3, "height": 16 } ] }]); let err = compile(narrow, json!({ "core_shaders": { "hover": true } }), 88).unwrap_err().to_string(); - assert!(err.contains("region `thin` is 3x16 px, but a hover outline is 4 to 256 px wide"), "{err}"); + assert!(err.contains("region `thin` has a 3x16 px hover outline, which must be 4 to 256 px wide"), "{err}"); +} + +#[test] +fn hover_art_keeps_its_colors_and_is_stretched_or_centered_on_its_region() { + let shape = |fill: &str, size: Option| { + let mut shape = json!({ "art": "shape", "kind": "panel", "fill": fill, "border_width": 0, "radius": 0, + "inset_depth": 0 }); + if let Some(size) = size { + shape.as_object_mut().unwrap().extend([("width".into(), json!(size)), ("height".into(), json!(size))]); + } + shape + }; + let children = json!([ + region(0, json!({ "on_click": click("frame"), "hover": shape("#ff0000", None) })), + region(1, json!({ "on_click": click("image"), "hover": shape("#00ff00", Some(19)) })), + region(2, json!({ "on_click": click("same"), "hover": shape("#00ff00", Some(19)) })), + region(3, json!({ "on_click": click("plain") })), + ]); + let output = compile(children, json!({ "core_shaders": { "hover": true } }), 88).unwrap(); + window_core::validation::validate_compile_output(&output).assert_valid(); + let regions = &output.manifest.windows["shop"].regions; + let hover = |name: &str| regions[name].hover.as_ref().unwrap(); + + // Art without a size of its own stretches over the region, so it starts where the region does. + assert_eq!((hover("frame").x, hover("frame").y), (-80, -66)); + assert_ne!(hover("frame").text, hover("plain").text, "art of the outline's size is another glyph"); + // The 19x19 image is centered on its 16x16 region, the odd pixel toward the top-left. + assert_eq!((hover("image").x, hover("image").y), (-64, -68)); + let glyph = hover("image").text.chars().next().unwrap(); + assert_eq!(hover("image").text, format!("{glyph}{}", spacers_for(-20))); + assert_eq!(hover("same").text, hover("image").text, "regions with the same art share its glyph"); + assert_eq!(hover("same").x, -46); + + let glyphs: Vec<&str> = paths(&output).into_iter().filter(|path| path.contains("/font/hover/")).collect(); + let [outline, image, frame] = glyphs[..] else { panic!("expected three hover glyphs: {glyphs:?}") }; + assert_eq!(outline, "assets/window/textures/font/hover/16x16.png"); + assert!(image.starts_with("assets/window/textures/font/hover/art/") && image.ends_with("/19x19.png"), "{image}"); + assert!(frame.starts_with("assets/window/textures/font/hover/art/") && frame.ends_with("/16x16.png"), "{frame}"); + + let file = output.files.iter().find(|file| file.path == frame).unwrap(); + let texture = Texture::decode_png(file.contents.as_bytes()).unwrap(); + assert_eq!((texture.width, texture.height), (16, 18), "the art sits between two data rows"); + let pixel = |x: usize, y: usize| &texture.rgba[(y * 16 + x) * 4..][..4]; + assert_eq!(pixel(0, 1), [0xff, 0, 0, 0xff]); + assert_eq!(pixel(15, 16), [0xff, 0, 0, 0xff]); + assert_eq!(pixel(1, 0), [15, 15, 0xa9, 0xff], "a data row holds the art's size minus one"); + assert_eq!(pixel(8, 17)[3], 0); + + let thin = json!([region(0, json!({ "on_click": click("thin"), "hover": shape("#ff0000", Some(3)) }))]); + let err = compile(thin, json!({ "core_shaders": { "hover": true } }), 88).unwrap_err().to_string(); + assert!(err.contains("region `thin` has a 3x3 px hover art, which must be 4 to 256 px wide"), "{err}"); + let err = compile(json!([region(0, json!({ "hover": "#ffffff" }))]), json!({}), 88).unwrap_err().to_string(); + assert!(err.contains("`hover` must be a boolean or an art value"), "{err}"); } #[test] diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index 73ab9017..c11ef0e4 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -65,8 +65,8 @@ window({ - `namespace`: namespace of the generated assets. Defaults to `window`. - `coreShaders.hud`: emit the core text shaders that place [``](#roots) HUDs. Opt-in, and sensitive to the client version. -- `coreShaders.hover`: emit the core text shaders that draw [hover outlines](#hover-outlines). Opt-in, and sensitive to - the client version. +- `coreShaders.hover`: emit the core text shaders that draw region [hovers](#hover). Opt-in, and sensitive to the client + version. - `experimental.anvilUpdates`: lets [anvil input windows](#native-anvil-search) change their title at runtime. - `size.report`: print a breakdown of the compiler's output after a successful build; see [What things cost](#what-things-cost). @@ -388,32 +388,39 @@ A tab group built from primitives: A section cell spans its slots' 18x18 boxes, while a control's face covers their 16x16 interiors, hence the `padding`. -### Hover outlines +### Hover -With the plugin option `coreShaders: { hover: true }`, the client draws a 1px white outline around a region's rectangle -while the cursor is over one of its slots. Vanilla highlights only the single hovered slot, which +With the plugin option `coreShaders: { hover: true }`, the client draws an outline or authored art over a region while +the cursor is over one of its slots. Vanilla highlights only the single hovered slot, which `vanilla: { slotHighlight: false }` removes. ```tsx - // outlined: it takes clicks - // never outlined + // a 1px white outline: it takes clicks + // nothing // outlined although it takes no clicks + // this art, in its own colors ``` -- A region is outlined by default when it has an `onClick`. `hover` overrides that either way, on `` and on the - industrial controls built on one (`Button`, `Toggle`, `Choice`, `Tabs`, `Repeater`, `Hotspot`); a control passes it - only to the states that take clicks. Setting `hover` while `coreShaders.hover` is off is a build error. -- The outline is drawn with the region's hitbox item, so an outlined region without a tooltip still fills its slots; its +- A region draws the white outline of its rectangle by default when it has an `onClick`. `hover` overrides that on + `` and on the industrial controls built on one (`Button`, `Toggle`, `Choice`, `Tabs`, `Repeater`, `Hotspot`): + `true` for the outline, `false` for nothing, or [inline art](#inline-art). A control passes it only to the states that + take clicks. Setting `hover` while `coreShaders.hover` is off is a build error. +- Hover art is drawn in its own colors and alpha, over everything else in the window. Art that stretches, which is a + texture with `insets` or a shape without `width` and `height`, covers the region's rectangle as a `frame` would. Other + art is drawn at its own size and centered on the region, so art 2px wider and taller than the region surrounds it by + 1px; when the sizes differ by an odd amount the art sits the odd pixel toward the top-left. Art may extend past the + region and the window. Pixels under 10% alpha are not drawn, and a texture id from another pack cannot be hover art. +- The hover is drawn with the region's hitbox item, so a region with a hover and no tooltip still fills its slots; its tooltip box is hidden. Slots where an `` or `` shows a stack show that stack's tooltip and no - outline. -- An outlined region is 4 to 256 px wide, at most 256 px tall, and starts within 128 px of the GUI's center; a region - outside those limits fails the build until its `hover` is `false`. -- The outline's color and thickness are fixed, and [previews](#previews) do not draw it. + hover. +- An outline or hover art is 4 to 256 px wide, at most 254 px tall, and starts within 128 px of the GUI's center; a + region outside those limits fails the build until its art is resized or its `hover` is `false`. +- The outline's color and thickness are fixed; use art for anything else. [Previews](#previews) do not draw hovers. - It needs pack format 85 through 88 (Minecraft 26.2 is 88), shares `core/text` with `coreShaders.hud`, and like it - depends on the client version. Where the outline crosses the region's own tooltip it is cut out, using the tooltip - size the server measures; advanced tooltips (F3+H) add lines the server cannot see, so the outline may show through - them. A tooltip the server cannot measure exactly (such as a translatable component) or that is wider than 2047 px or - longer than 29 lines gets no cut-out, so the whole outline is drawn and may show through the tooltip. + depends on the client version. Where the hover crosses the region's own tooltip it is cut out, using the tooltip size + the server measures; advanced tooltips (F3+H) add lines the server cannot see, so the hover may show through them. A + tooltip the server cannot measure exactly (such as a translatable component) or that is wider than 2047 px or longer + than 29 lines gets no cut-out, so the whole hover is drawn and may show through the tooltip. ### Inline art diff --git a/docs/MANIFEST.md b/docs/MANIFEST.md index c7d9c94e..f4409361 100644 --- a/docs/MANIFEST.md +++ b/docs/MANIFEST.md @@ -161,9 +161,9 @@ switch on its handle with one region per state. "item_model": "example:gui/buy", // absent ⇒ the pack's invisible hitbox model "tooltip": { "title": "Buy", "lines": ["Spend coins"] }, }, - // Optional hover outline (`coreShaders.hover`): the outline glyph and its net-zero spacers in the main - // font, and the region's top-left relative to the GUI center. The runtime leads the hitbox tooltip's title - // with it, filling the slots even without "hitbox". + // Optional hover (`coreShaders.hover`): the glyph of the outline or art and its net-zero spacers in the + // main font, and the top-left of where it is drawn relative to the GUI center. The runtime leads the hitbox + // tooltip's title with it, filling the slots even without "hitbox". "hover": { "text": "\uE040\uDB80\uDC0A", "x": -36, "y": 79 }, "source": "region `buy`", // the authored element, for diagnostics }, diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/InventoryEntries.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/InventoryEntries.kt index 79fe5327..29684e70 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/InventoryEntries.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/InventoryEntries.kt @@ -53,7 +53,7 @@ public data class RegionEntry( @SerialName("default_action") val defaultAction: String? = null, /** The item filling [filledSlots]; `null` leaves them empty unless [hover] is set. */ val hitbox: HitboxEntry? = null, - /** The outline drawn around the region while one of its [filledSlots] is hovered. */ + /** The outline or art drawn over the region while one of its [filledSlots] is hovered. */ val hover: HoverEntry? = null, /** The authored element this region comes from, such as ``region `buy` ``, for diagnostics. */ val source: String? = null, @@ -73,16 +73,16 @@ public data class HitboxEntry( ) /** - * A region's hover outline: what leads its hitbox item's tooltip title, which the pack's core text shader moves - * onto the region. + * A region's hover, an outline or art: what leads its hitbox item's tooltip title, which the pack's core text + * shader moves onto the region. */ @Serializable public data class HoverEntry( - /** The outline glyph and the spacers returning the cursor to its start, in the manifest's main font. */ + /** The hover glyph and the spacers returning the cursor to its start, in the manifest's main font. */ val text: String, - /** The region's left edge relative to the GUI center, in GUI pixels, within `-128..127`. */ + /** The left edge of what is drawn relative to the GUI center, in GUI pixels, within `-128..127`. */ val x: Int, - /** The region's top edge relative to the GUI center, in GUI pixels, within `-128..127`. */ + /** The top edge of what is drawn relative to the GUI center, in GUI pixels, within `-128..127`. */ val y: Int, ) diff --git a/plugin/src/theme/industrial/controls.tsx b/plugin/src/theme/industrial/controls.tsx index b0c4eb8b..3755fdca 100644 --- a/plugin/src/theme/industrial/controls.tsx +++ b/plugin/src/theme/industrial/controls.tsx @@ -228,8 +228,8 @@ export function Tabs(props: TabsProps): Element[] { export interface HotspotProps extends ItemStyle, DebugProps { tooltip: string | Tooltip; - /** Whether hovering outlines its slots; see ``. */ - hover?: boolean; + /** What hovering draws over its slots; see ``. */ + hover?: boolean | ArtRef; itemModel?: string; } diff --git a/plugin/src/theme/industrial/face.ts b/plugin/src/theme/industrial/face.ts index f1bdf131..2a8f65a9 100644 --- a/plugin/src/theme/industrial/face.ts +++ b/plugin/src/theme/industrial/face.ts @@ -23,8 +23,8 @@ export interface FaceProps { /** Frame of the face; a state's `frame` replaces it. */ frame?: ArtRef; tooltip?: string | Tooltip; - /** Whether hovering outlines the control where it takes clicks; see ``. */ - hover?: boolean; + /** What hovering draws over the control where it takes clicks; see ``. */ + hover?: boolean | ArtRef; itemModel?: string; /** Inset of the content from the face's edges. */ padding?: number; diff --git a/plugin/src/theme/industrial/slots.tsx b/plugin/src/theme/industrial/slots.tsx index afe8847c..05363091 100644 --- a/plugin/src/theme/industrial/slots.tsx +++ b/plugin/src/theme/industrial/slots.tsx @@ -56,8 +56,8 @@ export interface RepeaterProps extends ItemStyle, DebugProps { /** An action indexed by cell; a click calls it with the clicked cell's index. */ onClick?: Indexed; tooltip?: string | Tooltip | ((i: number) => string | Tooltip); - /** Whether hovering outlines a cell; see ``. */ - hover?: boolean; + /** What hovering draws over a cell; see ``. */ + hover?: boolean | ArtRef; /** Items indexed by cell, shown in each cell's `itemSlot`; clicks on it still call `onClick`. */ item?: Indexed; /** One-based slot of the cell `item` fills; defaults to 1. */ diff --git a/plugin/src/ui/components.ts b/plugin/src/ui/components.ts index 9d4dbb6b..7739d14b 100644 --- a/plugin/src/ui/components.ts +++ b/plugin/src/ui/components.ts @@ -138,10 +138,12 @@ export interface RegionProps extends ItemProps, DebugProps { onClick?: ClickAction; tooltip?: string | Tooltip; /** - * Whether hovering outlines the region. Only for packs with `coreShaders.hover`, where it defaults to whether - * the region has an `onClick`. + * What the region draws while one of its slots is hovered: `true` for a 1px white outline, `false` for nothing, + * or art drawn in its own colors. Art that stretches (a texture with `insets`, or a shape without a size) covers + * the region; other art is drawn at its own size, centered on the region. Only for packs with + * `coreShaders.hover`, where it defaults to whether the region has an `onClick`. */ - hover?: boolean; + hover?: boolean | ArtRef; /** Item model of the hitbox item filling its slots. */ itemModel?: string; width?: number; diff --git a/plugin/src/ui/document.ts b/plugin/src/ui/document.ts index 018292a5..4a970383 100644 --- a/plugin/src/ui/document.ts +++ b/plugin/src/ui/document.ts @@ -198,8 +198,8 @@ export interface RegionOptions extends FlexItemOptions, DebugName { /** What a click does; omit for a hover-only region. */ on_click?: ClickAction; tooltip?: string | Tooltip; - /** Whether hovering outlines the region; see `RegionProps.hover`. */ - hover?: boolean; + /** What the region draws while hovered: an outline, nothing, or art; see `RegionProps.hover`. */ + hover?: boolean | ArtRef; /** Item model of the hitbox item filling its slots. */ item_model?: string; /** Fixed size, set together; omit both to fill the parent box. */ diff --git a/plugin/test/golden/hover/expected.sha256 b/plugin/test/golden/hover/expected.sha256 index f69183f8..dd08bf62 100644 --- a/plugin/test/golden/hover/expected.sha256 +++ b/plugin/test/golden/hover/expected.sha256 @@ -1,24 +1,26 @@ -4f6916284edf45bc4bf994ae4c3fe39cf940c4ae28bdf9224e1d452e20f62b04 dist/assets/minecraft/shaders/core/text.fsh -7b8641102ecae50e0032b603c42295ac760c19596a57bb3263cc82d6ca401f68 dist/assets/minecraft/shaders/core/text.vsh +701d533913437a4993e75cbec30dbe9662218c86d58545155f5d3021f9405048 dist/assets/minecraft/shaders/core/text.fsh +69cceed3bf431ec67bc01212288c2147a65f67afb60710ba1469a685ff71e561 dist/assets/minecraft/shaders/core/text.vsh 7ab6f59f18462a31d7ad4d9f084d5bebe965bbd29d13fa18dc508af08771bab7 dist/assets/minecraft/shaders/core/text_background.vsh e7f52748deaf91eb9cad61d1f97598eb932341e86a78ab812393d5c80113f4dc dist/assets/minecraft/textures/gui/sprites/container/slot_highlight_back.png e7f52748deaf91eb9cad61d1f97598eb932341e86a78ab812393d5c80113f4dc dist/assets/minecraft/textures/gui/sprites/container/slot_highlight_front.png -a9f58426ab64c5ea468fa562bf009f745bd4f7b1d769d019d75db3baabd9c583 dist/assets/window/font/ui.json +7e685a95eb7ab4339f359c3e09e74b5ffb98a09723ffe7865963495174860243 dist/assets/window/font/ui.json 78a7156bf1d989484babf30892d474c79ddda3cc2b23d3a8cfb2bb1689aec7ac dist/assets/window/font/y16.json 93a0921adf078edd360459b3fb927865d9c73191541118f4b829f328eaccb9df dist/assets/window/items/gui/hitbox.json dc49481287414f39e74f7ef95a92814997f89f1b09b453bc34416f30787dd5b4 dist/assets/window/models/gui/hitbox.json -e261bec374bd49dba34c17303ea2ac2d0b023dfda0c9bce565e5dc4b6ccfe427 dist/assets/window/textures/font/hover/34x16.png -42fb108f0f22439e037e852a603ea9796980ee8932af79e90f21a8203aa77d04 dist/assets/window/textures/font/hover/52x16.png +731c0e23d874c775d2a8a9ff08af00e0d2ca2b5abe562425b895a6ff7d386ea2 dist/assets/window/textures/font/hover/34x16.png +15f01f678d53510f0389b02cb86e4c06a5855c77701c74139f0f001548da09e9 dist/assets/window/textures/font/hover/52x16.png +892a8428b895b747d41f50631d014d0c27eda5fee56e1b63a12f33a6b2be0b2f dist/assets/window/textures/font/hover/art/87de09/34x16.png +90bb9c47947aa065c12c440935cf217484c861b1698c72de2eeb641ae9348f94 dist/assets/window/textures/font/hover/art/b825bb/20x20.png 10d333fa3f5ad54d1d43c8ef4c46108f930b869f8daf7c19c6d8a9f9d500cf66 dist/assets/window/textures/font/menu.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/sprites/tooltip/hover_background.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/sprites/tooltip/hover_frame.png -7463268324bd11e78433a6f619217932a92b4fdcbb3dccc75b989302839be517 dist/assets/window/window/debug.json +b688e405d61ca18939252ff09d94ca91f738fac7533512d7931f48cc80aa5233 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta -6f18c3cd0888801b69c133ace1baf5b6829a10df61e7894e8d38e3af5d7ebe59 kotlin-out/MenuView.kt +d9615e8fbe5959ace3fe2fb5fd306191692f3f306becf9d02add6e6aaa96ae7c kotlin-out/MenuView.kt ae4ab3ba35ac560b4848f3fbf4d63f8a597497751e0fe105a7e9d57fca218127 kotlin-out/WaveHud.kt 4da3656b0f90f337fe44670b9a8881ea5eb66425dc87edd8d9bc8a6899602433 kotlin-out/WindowDefinitions.kt -a02c18b5865f9a19c0855edb3b8a80ca3b9361002b71faca1d1023129e5c7b6d kotlin-out/WindowEntries.kt +87317738d5cdad91f114903eb70fd005b406ca5593ba53ca72300ee84a5b6427 kotlin-out/WindowEntries.kt 9f703330d709b7d2b2a2275a9af91bc825b12a0bbb15ce52659197caefe8ded2 kotlin-out/WindowFonts.kt c2c8643dd4931ae9c40d8123f0f343f0e529ce59bc00899aa2a9937e275af631 kotlin-out/WindowHudDefinitions.kt 9e785e9fd4e745d8710ad08e28c8f3bf01cb4dbd8fb6263cde3ec376ed3b4f48 kotlin-out/WindowHudEntries.kt diff --git a/plugin/test/golden/hover/ts/window/menu.tsx b/plugin/test/golden/hover/ts/window/menu.tsx index 0d30e032..d66bd317 100644 --- a/plugin/test/golden/hover/ts/window/menu.tsx +++ b/plugin/test/golden/hover/ts/window/menu.tsx @@ -1,8 +1,8 @@ -import { Hud, Region, Section, Text, Window } from "plugin:window/ui"; +import { Hud, Region, Section, Text, Window, shape } from "plugin:window/ui"; import { action, text } from "plugin:window/bind"; import { Button } from "plugin:window/theme/industrial"; -// Outlined and plain regions beside a shader-placed HUD, so both core shader features share the text shaders. +// Outlined, art-hovered and plain regions beside a shader-placed HUD, so both core shader features share the text shaders. export default ( <> @@ -18,6 +18,12 @@ export default ( + {/* Art that stretches over its region, and art drawn at its own size around it. */} + + diff --git a/plugin/test/industrial.test.ts b/plugin/test/industrial.test.ts index ade3af65..13c41167 100644 --- a/plugin/test/industrial.test.ts +++ b/plugin/test/industrial.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { test } from "node:test"; import * as industrial from "../src/theme/industrial/index.ts"; -import { containerLayout, createTheme } from "../src/ui/index.ts"; +import { containerLayout, createTheme, shape } from "../src/ui/index.ts"; import { action, collection, flag, input, items, selection, toggle } from "../src/bind/index.ts"; import { Image, Window } from "../src/ui/components.ts"; import { resolveTokens } from "../src/ui/tokens.ts"; @@ -66,6 +66,12 @@ test("a disabled button shows its disabled state and takes no clicks", () => { ofType(quiet, "region").map((node) => node.hover), [false, undefined], ); + const lit = shape({ kind: "panel", fill: "#ff0000" }); + const art = industrial.Button({ onClick: action("buy"), enabled: flag("can_buy"), hover: lit }); + assert.deepEqual( + ofType(art, "region").map((node) => node.hover), + [lit, undefined], + ); assert.throws(() => industrial.Button({ onClick: action("x"), disabled: {} }), /enabled/); }); diff --git a/plugin/test/jsx.test.ts b/plugin/test/jsx.test.ts index 728f797f..54ff3200 100644 --- a/plugin/test/jsx.test.ts +++ b/plugin/test/jsx.test.ts @@ -82,6 +82,8 @@ test("handle props serialize the handle in place of a name", () => { }); assert.equal(fields(Region({ onClick: builtin("window:close"), hover: false }))["hover"], false); assert.equal("hover" in fields(Region({ onClick: builtin("window:close") })), false); + const art = texture("ui/hover.png", { insets: 2 }); + assert.equal(fields(Region({ onClick: builtin("window:close"), hover: art }))["hover"], art); }); test("selection helpers carry the compared or assigned value", () => { diff --git a/plugin/test/jsx.typecheck.tsx b/plugin/test/jsx.typecheck.tsx index 635a335b..121d39b5 100644 --- a/plugin/test/jsx.typecheck.tsx +++ b/plugin/test/jsx.typecheck.tsx @@ -128,9 +128,11 @@ const setterCondition = Switch({ bind: category.set("all"), children: [] }); const comparedClick = ; const quietRegion = ; const quietButton = - diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt index b3fb0b0f..5871ef0a 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt @@ -4038,7 +4038,7 @@ internal object WindowEntries { ), source = "region `upgrade[0]`", hover = HoverEntry( - text = "\uE35E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -80, y = -66, ), @@ -4098,7 +4098,7 @@ internal object WindowEntries { ), source = "region `upgrade[0]~2`", hover = HoverEntry( - text = "\uE35E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -80, y = -66, ), @@ -4158,7 +4158,7 @@ internal object WindowEntries { ), source = "region `upgrade[1]`", hover = HoverEntry( - text = "\uE35E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -26, y = -66, ), @@ -4218,7 +4218,7 @@ internal object WindowEntries { ), source = "region `upgrade[1]~2`", hover = HoverEntry( - text = "\uE35E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -26, y = -66, ), @@ -4278,7 +4278,7 @@ internal object WindowEntries { ), source = "region `upgrade[2]`", hover = HoverEntry( - text = "\uE35E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = 28, y = -66, ), @@ -4338,7 +4338,7 @@ internal object WindowEntries { ), source = "region `upgrade[2]~2`", hover = HoverEntry( - text = "\uE35E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = 28, y = -66, ), @@ -4368,9 +4368,9 @@ internal object WindowEntries { hitbox = null, source = "region `window:close`", hover = HoverEntry( - text = "\uF3F1\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", - x = 28, - y = 59, + text = "\uE23C\uDB80\uDC05\uDB80\uDC06\uDB80\uDC07\uDB80\uDC0A", + x = 26, + y = 57, ), ), ), From 3622e5ce3780ad930084591b1d351522e824a7fb Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:01:27 -0400 Subject: [PATCH 4/6] feat: give region hover art an explicit fit, a baked tint, and a preview option hover={art} now always stretches the art over the region like a frame. The object form { art, fit, tint } centers it at its own size or multiplies its colors at build time, and preview(..., { hovers: true }) draws every region's hover as the pack draws it. --- crates/window-core/src/authoring/art.rs | 82 ++++++++---- crates/window-core/src/authoring/element.rs | 8 +- .../src/authoring/element/build.rs | 7 +- crates/window-core/src/ir.rs | 2 +- crates/window-core/src/layout/visuals.rs | 30 ++--- crates/window-core/src/model.rs | 2 +- crates/window-core/src/model/flex.rs | 26 +++- crates/window-core/src/pipeline/hover.rs | 25 ++-- crates/window-core/src/pipeline/preview.rs | 20 +++ .../window-core/src/pipeline/preview/tests.rs | 39 ++++++ crates/window-core/tests/hover.rs | 119 +++++++++++++----- docs/AUTHORING.md | 34 +++-- example/pack/src/window/hover/hover_exit.png | Bin 117 -> 0 bytes example/pack/src/window/hover/hover_ring.png | Bin 86 -> 0 bytes plugin/src/theme/industrial/controls.tsx | 4 +- plugin/src/theme/industrial/face.ts | 4 +- plugin/src/theme/industrial/slots.tsx | 4 +- plugin/src/ui/components.ts | 8 +- plugin/src/ui/document.ts | 3 +- plugin/src/ui/index.ts | 2 + plugin/src/ui/previews.ts | 7 ++ plugin/src/ui/types.ts | 20 ++- plugin/test/golden/hover/expected.sha256 | 8 +- plugin/test/golden/hover/ts/window/menu.tsx | 8 +- plugin/test/industrial.test.ts | 2 +- plugin/test/jsx.test.ts | 2 + plugin/test/jsx.typecheck.tsx | 11 +- 27 files changed, 359 insertions(+), 118 deletions(-) delete mode 100644 example/pack/src/window/hover/hover_exit.png delete mode 100644 example/pack/src/window/hover/hover_ring.png diff --git a/crates/window-core/src/authoring/art.rs b/crates/window-core/src/authoring/art.rs index 08ec7369..cab4fd70 100644 --- a/crates/window-core/src/authoring/art.rs +++ b/crates/window-core/src/authoring/art.rs @@ -6,7 +6,8 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; use super::style::{FrameDto, SpriteDto}; -use crate::model::{Art, SpriteDef}; +use crate::ir::Rgb; +use crate::model::{Art, HoverFit, SpriteDef}; use crate::{Error, Result}; /// The prefix of every interned art name. Catalog sprite names cannot contain `/`, so interned names never clash @@ -92,11 +93,16 @@ pub(super) fn intern(table: &mut Art, art: &mut Option, usage: ArtUse #[derive(Clone, Debug)] pub(super) enum HoverDto { Outline(bool), - Art { - art: Option, - /// Whether the art stretches over the region as a frame, rather than being drawn at its own size. - stretch: bool, - }, + Art { art: Option, fit: HoverFit, tint: Option }, +} + +/// The object form of `hover`. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HoverArtDto { + art: ArtRefDto, + fit: Option, + tint: Option, } impl<'de> Deserialize<'de> for HoverDto { @@ -105,31 +111,61 @@ impl<'de> Deserialize<'de> for HoverDto { D: serde::Deserializer<'de>, { use serde::de::Error as _; - match serde_json::Value::deserialize(deserializer)? { - serde_json::Value::Bool(outline) => Ok(Self::Outline(outline)), - value => { - let ArtRefDto::Inline(inline) = serde_json::from_value(value) - .map_err(|_| D::Error::custom("`hover` must be a boolean or an art value"))? - else { - unreachable!("art values deserialize inline"); - }; - // Texture art has a size of its own unless it is nine-sliced; shape art only when it sets one. - let stretch = inline.def.contains_key("insets") - || (inline.art == "shape" - && !inline.def.contains_key("width") - && !inline.def.contains_key("height")); - Ok(Self::Art { art: Some(ArtRefDto::Inline(inline)), stretch }) - } + let value = match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::Bool(outline) => return Ok(Self::Outline(outline)), + value => value, + }; + // An art value names its kind in `art`; the object form holds an art value there. + if !value.get("art").is_some_and(serde_json::Value::is_object) { + let art = serde_json::from_value(value) + .map_err(|_| D::Error::custom("`hover` must be a boolean, an art value, or `{ art, fit, tint }`"))?; + return Ok(Self::Art { art: Some(art), fit: HoverFit::Stretch, tint: None }); } + let hover: HoverArtDto = + serde_json::from_value(value).map_err(|error| D::Error::custom(format!("`hover`: {error}")))?; + let fit = match hover.fit.as_deref() { + None | Some("stretch") => HoverFit::Stretch, + Some("center") => HoverFit::Center, + Some(other) => { + return Err(D::Error::custom(format!( + "`hover` has unknown `fit` `{other}`; expected \"stretch\" or \"center\"" + ))); + } + }; + let tint = + match hover.tint { + None => None, + Some(serde_json::Value::String(color)) => Some(Rgb::parse_hex(&color).ok_or_else(|| { + D::Error::custom(format!("`hover` has invalid `tint` `{color}`; expected #rrggbb")) + })?), + Some(_) => { + return Err(D::Error::custom( + "`hover` takes a fixed #rrggbb `tint`; a `tint(...)` handle is not supported on a hover", + )); + } + }; + Ok(Self::Art { art: Some(hover.art), fit, tint }) } } impl HoverDto { - /// Interns hover art into `table`, as a frame when it stretches and as an image otherwise. + /// Interns hover art into `table`, as a frame when it stretches and as an image when it is centered. pub(super) fn intern(&mut self, table: &mut Art) -> Result<()> { match self { Self::Outline(_) => Ok(()), - Self::Art { art, stretch } => intern(table, art, if *stretch { ArtUse::Frame } else { ArtUse::Image }), + Self::Art { art, fit: HoverFit::Stretch, .. } => intern(table, art, ArtUse::Frame), + Self::Art { art, fit: HoverFit::Center, .. } => { + if let Some(ArtRefDto::Inline(inline)) = art + && inline.art == "shape" + && !(inline.def.contains_key("width") && inline.def.contains_key("height")) + { + return Err(Error::Validation( + "`fit: \"center\"` draws the art at its own size, so its shape needs a `width` and `height`" + .into(), + )); + } + intern(table, art, ArtUse::Image) + } } } } diff --git a/crates/window-core/src/authoring/element.rs b/crates/window-core/src/authoring/element.rs index 2f27f90e..ef1e63d6 100644 --- a/crates/window-core/src/authoring/element.rs +++ b/crates/window-core/src/authoring/element.rs @@ -205,7 +205,13 @@ pub(super) fn intern_art(children: &mut [ElementDto], table: &mut Art) -> Result intern(table, &mut child.art, ArtUse::Image)?; intern(table, &mut child.selected_sprite, ArtUse::Sprite)?; if let Some(hover) = &mut child.hover { - hover.intern(table)?; + hover.intern(table).map_err(|error| { + let region = match &child.on_click { + Some(click) => format!("region `{}`", click.id()), + None => "region".to_string(), + }; + Error::Validation(format!("{region} `hover`: {error}")).with_debug_name(child.debug_name.as_deref()) + })?; } intern_art(&mut child.children, table)?; } diff --git a/crates/window-core/src/authoring/element/build.rs b/crates/window-core/src/authoring/element/build.rs index 47b7e94a..3c7ffbff 100644 --- a/crates/window-core/src/authoring/element/build.rs +++ b/crates/window-core/src/authoring/element/build.rs @@ -3,7 +3,7 @@ use crate::authoring::art::{ArtRefDto, HoverDto}; use crate::authoring::flex::parse_auto_flow; use crate::authoring::parse::validate_name; use crate::inventory::{InventorySlotSection, SlotRectClaim}; -use crate::model::{Bar, Element, FlexBox, Hover, Region, SlotSection, Switch, SwitchCase}; +use crate::model::{Bar, Element, FlexBox, Hover, HoverArt, Region, SlotSection, Switch, SwitchCase}; use crate::{Error, Result}; /// The widest and tallest a bar may be: one font atlas page, which its largest fill glyph must fit. @@ -204,8 +204,9 @@ impl ElementDto { None => None, Some(HoverDto::Outline(false)) => Some(Hover::None), Some(HoverDto::Outline(true)) => Some(Hover::Outline), - Some(HoverDto::Art { art, stretch: true }) => art_name(art)?.map(Hover::Frame), - Some(HoverDto::Art { art, stretch: false }) => art_name(art)?.map(Hover::Image), + Some(HoverDto::Art { art, fit, tint }) => { + art_name(art)?.map(|name| Hover::Art(HoverArt { name, fit: *fit, tint: *tint })) + } }; Ok(Element::Region(Box::new(Region { size: self.optional_size()?, diff --git a/crates/window-core/src/ir.rs b/crates/window-core/src/ir.rs index 14249ba7..352575b4 100644 --- a/crates/window-core/src/ir.rs +++ b/crates/window-core/src/ir.rs @@ -421,7 +421,7 @@ pub enum HoverIr { Art { /// The art's interned name under `frame/` or `image/`, the table it is interned in. name: String, - /// The art at its place in GUI space, which may extend past the region. + /// The art at its place in GUI space, which may extend past the region; [`Draw::Tinted`] when it is tinted. draw: Draw, }, } diff --git a/crates/window-core/src/layout/visuals.rs b/crates/window-core/src/layout/visuals.rs index 5549e299..6a17c63a 100644 --- a/crates/window-core/src/layout/visuals.rs +++ b/crates/window-core/src/layout/visuals.rs @@ -3,7 +3,7 @@ use super::target::LayoutTarget; use crate::Result; use crate::geometry::{Point, Rect, Size}; use crate::ir::{Align, ArtIr, Draw, HoverIr, Layer, SlotIr, SpriteSlotIr, TextureKey}; -use crate::model::{Bar, Frame, Hover, SpriteDef, TextFit, TextStyle, Tint}; +use crate::model::{Bar, Frame, Hover, HoverArt, HoverFit, SpriteDef, TextFit, TextStyle, Tint}; use crate::pipeline::texture_source_path; impl Solver<'_, T> { @@ -74,33 +74,33 @@ impl Solver<'_, T> { /// What `hover` draws over a region at `rect`. Art need not stay inside the pane: the client draws it over the /// whole screen. pub(super) fn hover_ir(&mut self, hover: &Hover, rect: Rect, referenced_by: &str) -> Result { - Ok(match hover { - Hover::None => HoverIr::None, - Hover::Outline => HoverIr::Outline, - Hover::Frame(name) => { + let Hover::Art(HoverArt { name, fit, tint }) = hover else { + return Ok(if *hover == Hover::None { HoverIr::None } else { HoverIr::Outline }); + }; + let (name, draw) = match fit { + HoverFit::Stretch => { let project = self.project; let def = project .art .frames .get(name) .ok_or_else(|| self.target.layout_err(format!("unknown frame `{name}`")))?; - HoverIr::Art { - name: format!("frame/{name}"), - draw: self.frame_def_draw(name, def, rect, referenced_by)?, - } + (format!("frame/{name}"), self.frame_def_draw(name, def, rect, referenced_by)?) } - Hover::Image(name) => { + HoverFit::Center => { let def = self.sprite_def(name)?; let size = self.sprite_size(name, def)?; // An odd difference in size leaves the art half a pixel off center, toward the top-left. let x = rect.x + (rect.width as i32 - size.width as i32).div_euclid(2); let y = rect.y + (rect.height as i32 - size.height as i32).div_euclid(2); - HoverIr::Art { - name: format!("image/{name}"), - draw: sprite_draw(def, Rect::new(x, y, size.width, size.height)), - } + (format!("image/{name}"), sprite_draw(def, Rect::new(x, y, size.width, size.height))) } - }) + }; + let draw = match tint { + Some(color) => Draw::Tinted { draw: Box::new(draw), color: *color }, + None => draw, + }; + Ok(HoverIr::Art { name, draw }) } /// Adds `draw` to the pane or switch case being emitted. diff --git a/crates/window-core/src/model.rs b/crates/window-core/src/model.rs index 7a6022c7..ff00b9b7 100644 --- a/crates/window-core/src/model.rs +++ b/crates/window-core/src/model.rs @@ -11,7 +11,7 @@ use crate::surface::ContainerKind; mod flex; mod generated; -pub use flex::{FlexBox, Hover, ItemLayout, LayoutChild, Region, SlotSection, Switch, SwitchCase}; +pub use flex::{FlexBox, Hover, HoverArt, HoverFit, ItemLayout, LayoutChild, Region, SlotSection, Switch, SwitchCase}; pub use generated::{GeneratedKind, GeneratedStyle}; /// The art a project draws, by interned name: inline art values and the runtime sprite catalog. diff --git a/crates/window-core/src/model/flex.rs b/crates/window-core/src/model/flex.rs index e838480e..bab58f6b 100644 --- a/crates/window-core/src/model/flex.rs +++ b/crates/window-core/src/model/flex.rs @@ -2,7 +2,7 @@ use crate::geometry::{Insets, Point, Size}; use crate::inventory::{InventorySlotSection, SlotRectClaim}; -use crate::ir::Tooltip; +use crate::ir::{Rgb, Tooltip}; use super::{Element, Tint}; @@ -82,10 +82,26 @@ pub enum Hover { None, /// The generated 1px white outline of its rect. Outline, - /// The named frame stretched over its rect. - Frame(String), - /// The named sprite at its own size, centered on its rect. - Image(String), + Art(HoverArt), +} + +/// Authored art a [`Region`] draws while hovered. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HoverArt { + /// The art's interned name: a frame for [`HoverFit::Stretch`], a sprite for [`HoverFit::Center`]. + pub name: String, + pub fit: HoverFit, + /// The color the art is multiplied by, baked in at compile time. + pub tint: Option, +} + +/// How hover art is placed on its region. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HoverFit { + /// Stretched over the region's rect, as a frame. + Stretch, + /// At its own size, centered on the region's rect, which it may overhang. + Center, } /// One case of a [`Switch`]: a box that fills the switch. diff --git a/crates/window-core/src/pipeline/hover.rs b/crates/window-core/src/pipeline/hover.rs index ac45c906..50a93f93 100644 --- a/crates/window-core/src/pipeline/hover.rs +++ b/crates/window-core/src/pipeline/hover.rs @@ -11,7 +11,7 @@ use crate::compose::{Texture, compose_draws}; use crate::font::{bitmap_provider, spacers_for}; use crate::geometry::{Rect, Size}; use crate::hud::{HOVER_GLYPH_ID, HOVER_SIZE_MARK}; -use crate::ir::{Draw, HoverIr, LaidOutWindow, RegionIr}; +use crate::ir::{Draw, HoverIr, LaidOutWindow, RegionIr, Rgb}; use crate::manifest::HoverEntry; use crate::size::Part; use crate::{Error, Result}; @@ -80,16 +80,16 @@ pub(super) fn check_disabled(windows: &[&LaidOutWindow]) -> Result<()> { } /// What a hovered region draws: authored art, or else the generated outline. -struct Shown<'a> { +pub(super) struct Shown<'a> { /// Where it is drawn, in GUI space. - rect: Rect, + pub(super) rect: Rect, /// The art's name among hover art and its draw over `rect`. art: Option<(&'a str, &'a Draw)>, } impl Shown<'_> { /// What `region` draws while hovered: as it says, else the outline when it takes clicks. - fn of(region: &RegionIr) -> Option> { + pub(super) fn of(region: &RegionIr) -> Option> { match ®ion.hover { Some(HoverIr::None) => None, Some(HoverIr::Art { name, draw }) => Some(Shown { rect: *draw.dest(), art: Some((name, draw)) }), @@ -102,10 +102,21 @@ impl Shown<'_> { fn glyph_key(&self) -> String { let Size { width, height } = self.rect.size(); match self.art { + Some((name, Draw::Tinted { color: Rgb { r, g, b }, .. })) => { + format!("hover/{name}/{r:02x}{g:02x}{b:02x}/{width}x{height}") + } Some((name, _)) => format!("hover/{name}/{width}x{height}"), None => format!("hover/{width}x{height}"), } } + + /// Its pixels, which cover `rect`. `window` names the window in errors. + pub(super) fn texture(&self, window: &str, textures: &BTreeMap) -> Result { + Ok(match self.art { + Some((_, draw)) => compose_draws(std::slice::from_ref(draw), window, textures)?.texture, + None => outline_texture(self.rect.size()), + }) + } } /// The codepoint allocation key of each distinct hover glyph among `windows`. @@ -150,11 +161,7 @@ impl CompileContext<'_> { let key = shown.glyph_key(); let glyph = char::from_u32(self.codepoints[&key]).expect("hover codepoint is a valid char"); if self.hover_glyphs.insert(glyph) { - let art = match shown.art { - Some((_, draw)) => compose_draws(std::slice::from_ref(draw), &w.name, self.textures)?.texture, - None => outline_texture(size), - }; - let texture = glyph_texture(&art); + let texture = glyph_texture(&shown.texture(&w.name, self.textures)?); // The text shader assumes ascent 0 when it finds the tooltip from the glyph's position. self.bitmap_providers.push(bitmap_provider(self.namespace, &key, texture.height, 0, glyph)?); let path = format!("assets/{}/textures/font/{key}.png", self.namespace); diff --git a/crates/window-core/src/pipeline/preview.rs b/crates/window-core/src/pipeline/preview.rs index bfe32538..082fee96 100644 --- a/crates/window-core/src/pipeline/preview.rs +++ b/crates/window-core/src/pipeline/preview.rs @@ -25,6 +25,7 @@ use crate::{Error, Result, text_font}; use super::bars::columns; use super::glyphs::{Layers, compose_layers}; +use super::hover::Shown; use super::metrics::{GlyphMetrics, bitmap_metrics}; use super::sprites::texture_source_path; use super::{CompileInput, OutputFile, anvil, decode_textures}; @@ -61,6 +62,10 @@ pub struct PreviewRequest { /// Whether to outline the active regions, which otherwise draw nothing. #[serde(default)] pub outlines: bool, + /// Whether to draw every region's hover at once, as the pack draws it. Draws nothing without + /// `coreShaders.hover`. + #[serde(default)] + pub hovers: bool, } /// The surface a preview draws. @@ -234,6 +239,7 @@ fn fail(preview: &str, message: String) -> Error { struct Pane<'a> { /// `window` or `HUD`, then the name, for error messages. label: String, + name: &'a str, /// The surface rect in GUI space. rect: Rect, /// The shadow of slot text. @@ -257,6 +263,7 @@ impl<'a> Pane<'a> { let Surface::Container(kind) = w.surface; Self { label: format!("window `{}`", w.name), + name: &w.name, rect: Rect::from_parts(Point::new(0, 0), kind.gui_size()), shadow: Shadow::Slot, container: Some(kind), @@ -277,6 +284,7 @@ impl<'a> Pane<'a> { fn hud(h: &'a LaidOutHud) -> Self { Self { label: format!("HUD `{}`", h.name), + name: &h.name, rect: Rect::new(0, 0, h.width, h.height), shadow: if h.shader.is_some() { Shadow::ShaderHud } else { Shadow::Slot }, container: None, @@ -617,6 +625,15 @@ impl Scene<'_> { for placement in placements.iter().flatten() { placement.include_in(&mut bounds); } + let hovers: Vec> = if request.hovers && self.project.options.core_shaders.hover { + let regions = pane.regions.iter().filter(|region| hidden.shows(Member::Region, ®ion.name)); + regions.filter_map(Shown::of).collect() + } else { + Vec::new() + }; + for Shown { rect, .. } in &hovers { + bounds.include(i64::from(rect.x), i64::from(rect.y), rect.width, rect.height); + } let mut canvas = Canvas::new(bounds.into_rect(&request.name, request.scale.unwrap_or(1))?); let field = ContainerKind::ANVIL_TEXT_FIELD; if pane.container == Some(ContainerKind::Anvil) @@ -678,6 +695,9 @@ impl Scene<'_> { let at = Point::new(field.x + ANVIL_TEXT_INSET.x, field.y + ANVIL_TEXT_INSET.y); self.painter.draw_line(&mut canvas, None, content, at, Rgb::new(0xff, 0xff, 0xff), Shadow::Vanilla, false); } + for shown in &hovers { + canvas.blit(&shown.texture(pane.name, self.textures)?, shown.rect.origin()); + } if request.outlines { for region in pane.regions.iter().filter(|region| hidden.shows(Member::Region, ®ion.name)) { canvas.outline(®ion.rect, REGION_OUTLINE); diff --git a/crates/window-core/src/pipeline/preview/tests.rs b/crates/window-core/src/pipeline/preview/tests.rs index 740d9914..9998200c 100644 --- a/crates/window-core/src/pipeline/preview/tests.rs +++ b/crates/window-core/src/pipeline/preview/tests.rs @@ -393,3 +393,42 @@ fn a_bar_draws_the_previewed_fraction_of_its_fill_and_defaults_to_full() { "preview `shot`: handle `hp` is a fraction and takes a number from 0 to 1; got 1.5" ); } + +#[test] +fn hovers_draw_what_each_region_draws_in_the_pack_where_it_draws_it() { + let region = |x: u32, fields: Value| { + let mut region = json!({ "type": "region", "width": 16, "height": 16 }); + region.as_object_mut().unwrap().extend(fields.as_object().unwrap().clone()); + json!({ "type": "flex", "x": x, "y": 18, "children": [region] }) + }; + let click = |id: &str| json!({ "kind": "action", "id": id }); + let art = json!({ "art": "shape", "kind": "panel", "fill": "#ffffff", "border_width": 0, "radius": 0, + "inset_depth": 0, "width": 20, "height": 20 }); + let children = json!([ + region(8, json!({ "on_click": click("outline") })), + region(26, json!({ "on_click": click("art"), "hover": { "art": art, "fit": "center", "tint": "#ff0000" } })), + region(44, json!({ "on_click": click("quiet"), "hover": false })), + region(62, json!({ "tooltip": "Info" })), + ]); + let render = |hover: bool, hovers: bool| { + let window = json!({ "name": "shop", "container": "generic_9x1", "children": children }); + let project = json!({ "windows": [window], "options": { "core_shaders": { "hover": hover } } }); + let target = json!({ "kind": "window", "name": "shop" }); + render_one(project, json!({ "name": "shot", "target": target, "hovers": hovers })).unwrap() + }; + + let shot = render(true, true); + let white = [0xff; 4]; + assert_eq!(shot.get(8, 18), white, "a clickable region draws its outline"); + assert_eq!(shot.get(23, 33), white); + assert_ne!(shot.get(9, 19), white, "the outline's interior is not drawn"); + // The 20x20 art is centered on the 16x16 region at (26, 18), so it overhangs by 2 px on every side. + assert_eq!(columns(&shot, RED), Some((24, 43))); + assert_eq!(shot.get(24, 16), RED); + let plain = render(true, false); + for x in [44, 62] { + assert_eq!(shot.get(x, 18), plain.get(x, 18), "a region that draws no hover in game draws none"); + } + assert_ne!(plain.get(8, 18), white); + assert_eq!(render(false, true).rgba, render(false, false).rgba, "nothing is drawn without `coreShaders.hover`"); +} diff --git a/crates/window-core/tests/hover.rs b/crates/window-core/tests/hover.rs index facb94a8..6142c607 100644 --- a/crates/window-core/tests/hover.rs +++ b/crates/window-core/tests/hover.rs @@ -1,4 +1,4 @@ -//! Hover outlines: which regions get one, what the pack gains for them, and the vanilla slot highlight override. +//! Hovers: which regions draw an outline or art, what the pack gains for them, and the vanilla slot highlight override. use std::collections::BTreeMap; @@ -11,7 +11,9 @@ use window_core::pipeline::{CompileInput, CompileOutput, compile_project_json}; fn compile(children: Value, options: Value, pack_format: u32) -> window_core::Result { let window = json!({ "name": "shop", "container": "generic_9x3", "children": children }); let project = json!({ "windows": [window], "options": options, "target": { "pack_format": pack_format } }); - compile_project_json(project.to_string().as_bytes(), &CompileInput::new(BTreeMap::new())) + let white = Texture { width: 4, height: 4, rgba: vec![0xff; 64] }.encode_png().unwrap(); + let files = BTreeMap::from([("white.png".to_string(), white)]); + compile_project_json(project.to_string().as_bytes(), &CompileInput::new(files)) } /// A 16x16 region over the container slot in column `column` of the first row, with `fields` added. @@ -116,48 +118,62 @@ fn hover_needs_its_core_shader_option_and_a_supported_pack_format() { assert!(err.contains("region `thin` has a 3x16 px hover outline, which must be 4 to 256 px wide"), "{err}"); } +/// A flat `fill` shape, `size` px square when it has a size of its own. +fn shape(fill: &str, size: Option) -> Value { + let mut shape = + json!({ "art": "shape", "kind": "panel", "fill": fill, "border_width": 0, "radius": 0, "inset_depth": 0 }); + if let Some(size) = size { + shape.as_object_mut().unwrap().extend([("width".into(), json!(size)), ("height".into(), json!(size))]); + } + shape +} + #[test] -fn hover_art_keeps_its_colors_and_is_stretched_or_centered_on_its_region() { - let shape = |fill: &str, size: Option| { - let mut shape = json!({ "art": "shape", "kind": "panel", "fill": fill, "border_width": 0, "radius": 0, - "inset_depth": 0 }); - if let Some(size) = size { - shape.as_object_mut().unwrap().extend([("width".into(), json!(size)), ("height".into(), json!(size))]); - } - shape - }; +fn hover_art_stretches_over_its_region_unless_it_is_centered() { let children = json!([ region(0, json!({ "on_click": click("frame"), "hover": shape("#ff0000", None) })), - region(1, json!({ "on_click": click("image"), "hover": shape("#00ff00", Some(19)) })), - region(2, json!({ "on_click": click("same"), "hover": shape("#00ff00", Some(19)) })), - region(3, json!({ "on_click": click("plain") })), + region(1, json!({ "on_click": click("sized"), "hover": shape("#ff0000", Some(19)) })), + region(2, json!({ "on_click": click("texture"), "hover": { "art": "texture", "texture": "white.png" } })), + region( + 3, + json!({ "on_click": click("center"), "hover": { "art": shape("#00ff00", Some(19)), "fit": "center" } }) + ), + region( + 4, + json!({ "on_click": click("same"), "hover": { "art": shape("#00ff00", Some(19)), "fit": "center" } }) + ), + region(5, json!({ "on_click": click("plain") })), ]); let output = compile(children, json!({ "core_shaders": { "hover": true } }), 88).unwrap(); window_core::validation::validate_compile_output(&output).assert_valid(); let regions = &output.manifest.windows["shop"].regions; let hover = |name: &str| regions[name].hover.as_ref().unwrap(); - // Art without a size of its own stretches over the region, so it starts where the region does. + // Stretched art starts where its region does, whatever size of its own it has. assert_eq!((hover("frame").x, hover("frame").y), (-80, -66)); assert_ne!(hover("frame").text, hover("plain").text, "art of the outline's size is another glyph"); - // The 19x19 image is centered on its 16x16 region, the odd pixel toward the top-left. - assert_eq!((hover("image").x, hover("image").y), (-64, -68)); - let glyph = hover("image").text.chars().next().unwrap(); - assert_eq!(hover("image").text, format!("{glyph}{}", spacers_for(-20))); - assert_eq!(hover("same").text, hover("image").text, "regions with the same art share its glyph"); - assert_eq!(hover("same").x, -46); + assert_eq!(hover("sized").text, hover("frame").text, "a stretched shape's own size does not apply"); + assert_eq!((hover("texture").x, hover("texture").text.len()), (-44, hover("frame").text.len())); + // The 19x19 art is centered on its 16x16 region, the odd pixel toward the top-left. + assert_eq!((hover("center").x, hover("center").y), (-28, -68)); + let glyph = hover("center").text.chars().next().unwrap(); + assert_eq!(hover("center").text, format!("{glyph}{}", spacers_for(-20))); + assert_eq!(hover("same").text, hover("center").text, "regions with the same art share its glyph"); + assert_eq!(hover("same").x, -10); let glyphs: Vec<&str> = paths(&output).into_iter().filter(|path| path.contains("/font/hover/")).collect(); - let [outline, frame, image] = glyphs[..] else { panic!("expected three hover glyphs: {glyphs:?}") }; + let [outline, frame, texture, image] = glyphs[..] else { panic!("expected four hover glyphs: {glyphs:?}") }; assert_eq!(outline, "assets/window/textures/font/hover/16x16.png"); assert!( image.starts_with("assets/window/textures/font/hover/image/art/") && image.ends_with("/19x19.png"), "{image}" ); - assert!( - frame.starts_with("assets/window/textures/font/hover/frame/art/") && frame.ends_with("/16x16.png"), - "{frame}" - ); + for stretched in [frame, texture] { + assert!( + stretched.starts_with("assets/window/textures/font/hover/frame/art/") && stretched.ends_with("/16x16.png"), + "{stretched}" + ); + } let file = output.files.iter().find(|file| file.path == frame).unwrap(); let texture = Texture::decode_png(file.contents.as_bytes()).unwrap(); @@ -167,12 +183,53 @@ fn hover_art_keeps_its_colors_and_is_stretched_or_centered_on_its_region() { assert_eq!(pixel(15, 16), [0xff, 0, 0, 0xff]); assert_eq!(pixel(1, 0), [15, 15, 0xa9, 0xff], "a data row holds the art's size minus one"); assert_eq!(pixel(8, 17)[3], 0); +} + +#[test] +fn hover_tint_is_baked_into_the_glyph_and_told_apart_by_color() { + let white = json!({ "art": "texture", "texture": "white.png" }); + let tinted = |name: &str, column: i32, tint: &str| { + region(column, json!({ "on_click": click(name), "hover": { "art": white, "tint": tint } })) + }; + let children = json!([ + region(0, json!({ "on_click": click("white"), "hover": white })), + tinted("gold", 1, "#ffcc00"), + tinted("gold_too", 2, "#ffcc00"), + tinted("red", 3, "#ff0000"), + ]); + let output = compile(children, json!({ "core_shaders": { "hover": true } }), 88).unwrap(); + let regions = &output.manifest.windows["shop"].regions; + let glyph = |name: &str| regions[name].hover.as_ref().unwrap().text.clone(); + assert_eq!(glyph("gold"), glyph("gold_too"), "the same art in the same tint is one glyph"); + assert_ne!(glyph("gold"), glyph("red")); + assert_ne!(glyph("gold"), glyph("white")); + + let gold = output.files.iter().find(|file| file.path.ends_with("/ffcc00/16x16.png")).expect("a gold glyph"); + let texture = Texture::decode_png(gold.contents.as_bytes()).unwrap(); + assert_eq!(texture.rgba[16 * 4..][..4], [0xff, 0xcc, 0, 0xff], "the art's white is multiplied by the tint"); +} + +#[test] +fn invalid_hovers_fail_naming_what_is_wrong() { + let options = || json!({ "core_shaders": { "hover": true } }); + let err = |hover: Value| { + let children = json!([region(0, json!({ "on_click": click("bad"), "hover": hover }))]); + compile(children, options(), 88).unwrap_err().to_string() + }; - let thin = json!([region(0, json!({ "on_click": click("thin"), "hover": shape("#ff0000", Some(3)) }))]); - let err = compile(thin, json!({ "core_shaders": { "hover": true } }), 88).unwrap_err().to_string(); - assert!(err.contains("region `thin` has a 3x3 px hover art, which must be 4 to 256 px wide"), "{err}"); - let err = compile(json!([region(0, json!({ "hover": "#ffffff" }))]), json!({}), 88).unwrap_err().to_string(); - assert!(err.contains("`hover` must be a boolean or an art value"), "{err}"); + let thin = err(json!({ "art": shape("#ff0000", Some(3)), "fit": "center" })); + assert!(thin.contains("region `bad` has a 3x3 px hover art, which must be 4 to 256 px wide"), "{thin}"); + let unsized_art = err(json!({ "art": shape("#ff0000", None), "fit": "center" })); + assert!( + unsized_art.contains("region `bad` `hover`: `fit: \"center\"` draws the art at its own size"), + "{unsized_art}" + ); + let handle = err(json!({ "art": shape("#ff0000", None), "tint": { "id": "tone", "kind": "tint" } })); + assert!(handle.contains("a `tint(...)` handle is not supported on a hover"), "{handle}"); + let fit = err(json!({ "art": shape("#ff0000", None), "fit": "cover" })); + assert!(fit.contains("`hover` has unknown `fit` `cover`"), "{fit}"); + let color = err(json!("#ffffff")); + assert!(color.contains("`hover` must be a boolean, an art value, or `{ art, fit, tint }`"), "{color}"); } #[test] diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index c11ef0e4..50f00362 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -173,6 +173,9 @@ of several), a list of `[handle, value]` entries, and: - `scale`: an integer from 1 to 16 that upscales the image with nearest-neighbour sampling. Defaults to 1, one image pixel per GUI pixel. - `outlines`: outline the active [regions](#primitives), which otherwise draw nothing. +- `hovers`: draw the [hover](#hover) of every active region at once, each where and as the pack draws it in game, + without the tooltip cut-out. A region that draws no hover in game draws none, and nothing is drawn unless the pack + sets `coreShaders.hover`. An image may hold at most 16,777,216 pixels (4096 x 4096) after scaling; a larger one fails the build naming the preview, its size, and its scale. @@ -395,27 +398,40 @@ the cursor is over one of its slots. Vanilla highlights only the single hovered `vanilla: { slotHighlight: false }` removes. ```tsx +const ring = texture("ui/ring.png", { insets: 2 }); // a white ring + // a 1px white outline: it takes clicks // nothing // outlined although it takes no clicks - // this art, in its own colors + // the ring stretched over the button + // the same ring in gold + // art at its own size ``` - A region draws the white outline of its rectangle by default when it has an `onClick`. `hover` overrides that on `` and on the industrial controls built on one (`Button`, `Toggle`, `Choice`, `Tabs`, `Repeater`, `Hotspot`): - `true` for the outline, `false` for nothing, or [inline art](#inline-art). A control passes it only to the states that - take clicks. Setting `hover` while `coreShaders.hover` is off is a build error. -- Hover art is drawn in its own colors and alpha, over everything else in the window. Art that stretches, which is a - texture with `insets` or a shape without `width` and `height`, covers the region's rectangle as a `frame` would. Other - art is drawn at its own size and centered on the region, so art 2px wider and taller than the region surrounds it by - 1px; when the sizes differ by an odd amount the art sits the odd pixel toward the top-left. Art may extend past the - region and the window. Pixels under 10% alpha are not drawn, and a texture id from another pack cannot be hover art. + `true` for the outline, `false` for nothing, [inline art](#inline-art), or `{ art, fit, tint }`. Its type is exported + as `Hover`. A control passes it only to the states that take clicks. Setting `hover` while `coreShaders.hover` is off + is a build error. +- Hover art is drawn in its own colors and alpha, over everything else in the window. Pixels under 10% alpha are not + drawn, and a texture id from another pack cannot be hover art. +- `fit` places the art. `"stretch"`, the default and what `hover={art}` means, stretches it over the region's rectangle + exactly as `frame={art}` would: a texture's `insets` nine-slice it, and the art's own `width` and `height` do not + apply. `"center"` draws the art at its own size, centered on the region; when the sizes differ by an odd amount the + art sits the odd pixel toward the top-left. A texture's own size is its image's unless it sets `width` and `height`; a + shape has none without them, so centering one is a build error naming the region. +- To draw a hover around a control instead of over it, center art larger than the control: art 4px wider and taller than + the region overhangs it by 2px on every side. Centered art may extend past the region and the window. +- `tint` multiplies the art's colors by a `#rrggbb` color or a [var](#vars-and-themes) resolving to one, so one white + texture serves hovers of several colors. The color is baked into the pack at build time; a `tint(...)` handle is a + type error and a build error. The white outline takes no tint; tint art instead. - The hover is drawn with the region's hitbox item, so a region with a hover and no tooltip still fills its slots; its tooltip box is hidden. Slots where an `` or `` shows a stack show that stack's tooltip and no hover. - An outline or hover art is 4 to 256 px wide, at most 254 px tall, and starts within 128 px of the GUI's center; a region outside those limits fails the build until its art is resized or its `hover` is `false`. -- The outline's color and thickness are fixed; use art for anything else. [Previews](#previews) do not draw hovers. +- The outline's color and thickness are fixed; use art for anything else. A [preview](#previews) draws hovers with + `hovers: true`. - It needs pack format 85 through 88 (Minecraft 26.2 is 88), shares `core/text` with `coreShaders.hud`, and like it depends on the client version. Where the hover crosses the region's own tooltip it is cut out, using the tooltip size the server measures; advanced tooltips (F3+H) add lines the server cannot see, so the hover may show through them. A diff --git a/example/pack/src/window/hover/hover_exit.png b/example/pack/src/window/hover/hover_exit.png deleted file mode 100644 index 18caa937a499804b83df2cba86d7679fcc5294ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^7CS)de=bf=kh$9>i9rqA+X?DUq}N-H3P1l0|HH(BWO7<||uy8S-T90pHUKbLh* G2~7ahnk8fa diff --git a/example/pack/src/window/hover/hover_ring.png b/example/pack/src/window/hover/hover_ring.png deleted file mode 100644 index 67a860c41c50cd1f67c3c0182ce3bb90df03381e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86 zcmeAS@N?(olHy`uVBq!ia0vp^tRT$61|)m))t&+=Sx*(props: TabsProps): Element[] { export interface HotspotProps extends ItemStyle, DebugProps { tooltip: string | Tooltip; /** What hovering draws over its slots; see ``. */ - hover?: boolean | ArtRef; + hover?: Hover; itemModel?: string; } diff --git a/plugin/src/theme/industrial/face.ts b/plugin/src/theme/industrial/face.ts index 2a8f65a9..30cf5787 100644 --- a/plugin/src/theme/industrial/face.ts +++ b/plugin/src/theme/industrial/face.ts @@ -2,7 +2,7 @@ import type { Child, DebugProps } from "../../ui/components.ts"; import { assign } from "../../ui/styles.ts"; import type { ContainerStyle, ItemStyle, StyleFor, StyleValue, TextProps, Variants } from "../../ui/styles.ts"; -import type { ArtRef, FixedLength, Tooltip } from "../../ui/types.ts"; +import type { ArtRef, FixedLength, Hover, Tooltip } from "../../ui/types.ts"; export type Layer = StyleValue; @@ -24,7 +24,7 @@ export interface FaceProps { frame?: ArtRef; tooltip?: string | Tooltip; /** What hovering draws over the control where it takes clicks; see ``. */ - hover?: boolean | ArtRef; + hover?: Hover; itemModel?: string; /** Inset of the content from the face's edges. */ padding?: number; diff --git a/plugin/src/theme/industrial/slots.tsx b/plugin/src/theme/industrial/slots.tsx index 05363091..db32afd6 100644 --- a/plugin/src/theme/industrial/slots.tsx +++ b/plugin/src/theme/industrial/slots.tsx @@ -4,7 +4,7 @@ import type { Action, Indexed, Items as ItemsHandle } from "../../bind/handles.t import type { Element } from "../../ui/document.ts"; import type { Child, CollectionProps as CollectionPrimitiveProps, DebugProps } from "../../ui/components.ts"; import type { BoxStyle, ItemStyle, StyleValue, TextProps } from "../../ui/styles.ts"; -import type { ArtRef, Tooltip } from "../../ui/types.ts"; +import type { ArtRef, Hover, Tooltip } from "../../ui/types.ts"; import { placement } from "./face.ts"; import type { Layer, VariantStyle } from "./face.ts"; import { styles } from "./styles.ts"; @@ -57,7 +57,7 @@ export interface RepeaterProps extends ItemStyle, DebugProps { onClick?: Indexed; tooltip?: string | Tooltip | ((i: number) => string | Tooltip); /** What hovering draws over a cell; see ``. */ - hover?: boolean | ArtRef; + hover?: Hover; /** Items indexed by cell, shown in each cell's `itemSlot`; clicks on it still call `onClick`. */ item?: Indexed; /** One-based slot of the cell `item` fills; defaults to 1. */ diff --git a/plugin/src/ui/components.ts b/plugin/src/ui/components.ts index 7739d14b..e7bd3be4 100644 --- a/plugin/src/ui/components.ts +++ b/plugin/src/ui/components.ts @@ -24,6 +24,7 @@ import type { ArtRef, ContainerKind, GridFlow, + Hover, HudChannel, Insets, SlotList, @@ -139,11 +140,10 @@ export interface RegionProps extends ItemProps, DebugProps { tooltip?: string | Tooltip; /** * What the region draws while one of its slots is hovered: `true` for a 1px white outline, `false` for nothing, - * or art drawn in its own colors. Art that stretches (a texture with `insets`, or a shape without a size) covers - * the region; other art is drawn at its own size, centered on the region. Only for packs with - * `coreShaders.hover`, where it defaults to whether the region has an `onClick`. + * art stretched over the region in its own colors, or `{ art, fit, tint }` to center or tint it. Only for packs + * with `coreShaders.hover`, where it defaults to whether the region has an `onClick`. */ - hover?: boolean | ArtRef; + hover?: Hover; /** Item model of the hitbox item filling its slots. */ itemModel?: string; width?: number; diff --git a/plugin/src/ui/document.ts b/plugin/src/ui/document.ts index 4a970383..70a7b7f1 100644 --- a/plugin/src/ui/document.ts +++ b/plugin/src/ui/document.ts @@ -20,6 +20,7 @@ import type { FixedLength, FlexAlign, FlexJustify, + Hover, GridFlow, GridLine, HudChannel, @@ -199,7 +200,7 @@ export interface RegionOptions extends FlexItemOptions, DebugName { on_click?: ClickAction; tooltip?: string | Tooltip; /** What the region draws while hovered: an outline, nothing, or art; see `RegionProps.hover`. */ - hover?: boolean | ArtRef; + hover?: Hover; /** Item model of the hitbox item filling its slots. */ item_model?: string; /** Fixed size, set together; omit both to fill the parent box. */ diff --git a/plugin/src/ui/index.ts b/plugin/src/ui/index.ts index 09535dff..9817bab5 100644 --- a/plugin/src/ui/index.ts +++ b/plugin/src/ui/index.ts @@ -12,6 +12,8 @@ export type { GeneratedKind, GridFlow, GridLine, + Hover, + HoverArt, HudChannel, HudShader, HudShaderPoint, diff --git a/plugin/src/ui/previews.ts b/plugin/src/ui/previews.ts index 510efd71..7e9810ec 100644 --- a/plugin/src/ui/previews.ts +++ b/plugin/src/ui/previews.ts @@ -58,6 +58,11 @@ export interface PreviewOptions { scale?: number; /** Outline the active regions, which otherwise draw nothing. */ outlines?: boolean; + /** + * Draw every region's hover at once, as the pack draws it in game. Draws nothing unless the pack sets + * `coreShaders.hover`. + */ + hovers?: boolean; } /** A preview as the compiler reads it. */ @@ -67,6 +72,7 @@ export interface Preview { readonly state: readonly { readonly handle: string; readonly at?: readonly number[]; readonly value: unknown }[]; readonly scale?: number; readonly outlines?: boolean; + readonly hovers?: boolean; } type Fields = Record; @@ -134,5 +140,6 @@ export function preview( state: entries, ...(options.scale === undefined ? {} : { scale: options.scale }), ...(options.outlines === undefined ? {} : { outlines: options.outlines }), + ...(options.hovers === undefined ? {} : { hovers: options.hovers }), }; } diff --git a/plugin/src/ui/types.ts b/plugin/src/ui/types.ts index 0a0106fb..d778b603 100644 --- a/plugin/src/ui/types.ts +++ b/plugin/src/ui/types.ts @@ -1,4 +1,4 @@ -import type { Color, Var } from "./tokens.ts"; +import type { Color, Token, Var } from "./tokens.ts"; export type TextAlign = "left" | "center" | "right"; export type ContainerKind = @@ -210,6 +210,24 @@ export type Art = TextureArt | ShapeArt; /** Inline art, or art `derive`d from tokens. */ export type ArtRef = Art | Var; +/** Art a region draws while hovered, with its placement and color. */ +export interface HoverArt { + art: ArtRef; + /** + * `"stretch"`, the default, stretches the art over the region as a `frame` would. `"center"` draws it at its own + * size centered on the region, which it may overhang; a shape then needs a `width` and `height`. + */ + fit?: "stretch" | "center"; + /** Multiplies the art's colors, baked in at build time. A `tint` handle is not supported. */ + tint?: Token; +} + +/** + * What a region draws while one of its slots is hovered: `true` for a 1px white outline, `false` for nothing, art + * stretched over the region, or a `HoverArt` that also sets its `fit` and `tint`. + */ +export type Hover = boolean | ArtRef | HoverArt; + /** * A bitmap text font: a glyph sheet of 8px-tall cells that each `chars` row maps, left to right. Cells sit on the text * line like vanilla `ascii.png` cells: seven rows above the baseline and one below. diff --git a/plugin/test/golden/hover/expected.sha256 b/plugin/test/golden/hover/expected.sha256 index 1895234a..132db47e 100644 --- a/plugin/test/golden/hover/expected.sha256 +++ b/plugin/test/golden/hover/expected.sha256 @@ -3,24 +3,24 @@ 7ab6f59f18462a31d7ad4d9f084d5bebe965bbd29d13fa18dc508af08771bab7 dist/assets/minecraft/shaders/core/text_background.vsh e7f52748deaf91eb9cad61d1f97598eb932341e86a78ab812393d5c80113f4dc dist/assets/minecraft/textures/gui/sprites/container/slot_highlight_back.png e7f52748deaf91eb9cad61d1f97598eb932341e86a78ab812393d5c80113f4dc dist/assets/minecraft/textures/gui/sprites/container/slot_highlight_front.png -6e9fac6d1e3863b19cfc15e48305c6cda985e73002c7cdd6e5c7bb64a918528d dist/assets/window/font/ui.json +4e8c783bc58337721b4b88ae7f5d454a323f6f0041bc5b8c74c3acf09a163327 dist/assets/window/font/ui.json 78a7156bf1d989484babf30892d474c79ddda3cc2b23d3a8cfb2bb1689aec7ac dist/assets/window/font/y16.json 93a0921adf078edd360459b3fb927865d9c73191541118f4b829f328eaccb9df dist/assets/window/items/gui/hitbox.json dc49481287414f39e74f7ef95a92814997f89f1b09b453bc34416f30787dd5b4 dist/assets/window/models/gui/hitbox.json 731c0e23d874c775d2a8a9ff08af00e0d2ca2b5abe562425b895a6ff7d386ea2 dist/assets/window/textures/font/hover/34x16.png 15f01f678d53510f0389b02cb86e4c06a5855c77701c74139f0f001548da09e9 dist/assets/window/textures/font/hover/52x16.png 892a8428b895b747d41f50631d014d0c27eda5fee56e1b63a12f33a6b2be0b2f dist/assets/window/textures/font/hover/frame/art/87de09/34x16.png -90bb9c47947aa065c12c440935cf217484c861b1698c72de2eeb641ae9348f94 dist/assets/window/textures/font/hover/image/art/b825bb/20x20.png +c02e754abe6973df7cfea78e59d8a0294fe7df7ee65375af45534b60234c1974 dist/assets/window/textures/font/hover/image/art/46f699/ff5555/20x20.png 10d333fa3f5ad54d1d43c8ef4c46108f930b869f8daf7c19c6d8a9f9d500cf66 dist/assets/window/textures/font/menu.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/sprites/tooltip/hover_background.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/sprites/tooltip/hover_frame.png -c5988095cd42ddb505500d5216a5618e7307b2120edf45963906cf5acb5f49f8 dist/assets/window/window/debug.json +c10433f3bc07424fdee32b4becfb9c77af102f9100885102392a4d1a462cc3d9 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta d9615e8fbe5959ace3fe2fb5fd306191692f3f306becf9d02add6e6aaa96ae7c kotlin-out/MenuView.kt ae4ab3ba35ac560b4848f3fbf4d63f8a597497751e0fe105a7e9d57fca218127 kotlin-out/WaveHud.kt 4da3656b0f90f337fe44670b9a8881ea5eb66425dc87edd8d9bc8a6899602433 kotlin-out/WindowDefinitions.kt -815912afdb34834e3bad7f623fa0aebcfb539fbd6bad6df8d5ed92eace47ed0c kotlin-out/WindowEntries.kt +23342b660464b9291fa8fcb58a6ed153cf7bda511669709753453634cac5e9db kotlin-out/WindowEntries.kt 9f703330d709b7d2b2a2275a9af91bc825b12a0bbb15ce52659197caefe8ded2 kotlin-out/WindowFonts.kt c2c8643dd4931ae9c40d8123f0f343f0e529ce59bc00899aa2a9937e275af631 kotlin-out/WindowHudDefinitions.kt 9e785e9fd4e745d8710ad08e28c8f3bf01cb4dbd8fb6263cde3ec376ed3b4f48 kotlin-out/WindowHudEntries.kt diff --git a/plugin/test/golden/hover/ts/window/menu.tsx b/plugin/test/golden/hover/ts/window/menu.tsx index d66bd317..8184eb75 100644 --- a/plugin/test/golden/hover/ts/window/menu.tsx +++ b/plugin/test/golden/hover/ts/window/menu.tsx @@ -18,11 +18,15 @@ export default ( - {/* Art that stretches over its region, and art drawn at its own size around it. */} + {/* Art that stretches over its region, and tinted art drawn at its own size around it. */} diff --git a/plugin/test/industrial.test.ts b/plugin/test/industrial.test.ts index 13c41167..ebb6f2ad 100644 --- a/plugin/test/industrial.test.ts +++ b/plugin/test/industrial.test.ts @@ -66,7 +66,7 @@ test("a disabled button shows its disabled state and takes no clicks", () => { ofType(quiet, "region").map((node) => node.hover), [false, undefined], ); - const lit = shape({ kind: "panel", fill: "#ff0000" }); + const lit = { art: shape({ kind: "panel", fill: "#ffffff" }), tint: "#ff0000" } as const; const art = industrial.Button({ onClick: action("buy"), enabled: flag("can_buy"), hover: lit }); assert.deepEqual( ofType(art, "region").map((node) => node.hover), diff --git a/plugin/test/jsx.test.ts b/plugin/test/jsx.test.ts index 54ff3200..2a4b5833 100644 --- a/plugin/test/jsx.test.ts +++ b/plugin/test/jsx.test.ts @@ -84,6 +84,8 @@ test("handle props serialize the handle in place of a name", () => { assert.equal("hover" in fields(Region({ onClick: builtin("window:close") })), false); const art = texture("ui/hover.png", { insets: 2 }); assert.equal(fields(Region({ onClick: builtin("window:close"), hover: art }))["hover"], art); + const centered = { art, fit: "center", tint: "#ffcc00" } as const; + assert.equal(fields(Region({ onClick: builtin("window:close"), hover: centered }))["hover"], centered); }); test("selection helpers carry the compared or assigned value", () => { diff --git a/plugin/test/jsx.typecheck.tsx b/plugin/test/jsx.typecheck.tsx index 121d39b5..b5a99655 100644 --- a/plugin/test/jsx.typecheck.tsx +++ b/plugin/test/jsx.typecheck.tsx @@ -130,9 +130,18 @@ const quietRegion = ; const quietButton = - {/* The art is 2px larger than the button on every side, so it is centered around it. */} diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt index 5871ef0a..52704b55 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/WindowEntries.kt @@ -4038,7 +4038,7 @@ internal object WindowEntries { ), source = "region `upgrade[0]`", hover = HoverEntry( - text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE8EF\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -80, y = -66, ), @@ -4098,7 +4098,7 @@ internal object WindowEntries { ), source = "region `upgrade[0]~2`", hover = HoverEntry( - text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE8EF\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -80, y = -66, ), @@ -4158,7 +4158,7 @@ internal object WindowEntries { ), source = "region `upgrade[1]`", hover = HoverEntry( - text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE8EF\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -26, y = -66, ), @@ -4218,7 +4218,7 @@ internal object WindowEntries { ), source = "region `upgrade[1]~2`", hover = HoverEntry( - text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE8EF\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = -26, y = -66, ), @@ -4278,7 +4278,7 @@ internal object WindowEntries { ), source = "region `upgrade[2]`", hover = HoverEntry( - text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE8EF\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = 28, y = -66, ), @@ -4338,7 +4338,7 @@ internal object WindowEntries { ), source = "region `upgrade[2]~2`", hover = HoverEntry( - text = "\uE3A6\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE8EF\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = 28, y = -66, ), @@ -4368,9 +4368,9 @@ internal object WindowEntries { hitbox = null, source = "region `window:close`", hover = HoverEntry( - text = "\uE23C\uDB80\uDC05\uDB80\uDC06\uDB80\uDC07\uDB80\uDC0A", - x = 26, - y = 57, + text = "\uF14E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + x = 28, + y = 59, ), ), ), From 319bc697b745b5dd6d3679b12cb88fe88fe0d679 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:07:59 -0400 Subject: [PATCH 6/6] fix(core): make hover previews match what the pack draws --- crates/window-core/src/pipeline/hover.rs | 47 ++++++++++--------- crates/window-core/src/pipeline/preview.rs | 27 ++++++++--- .../window-core/src/pipeline/preview/tests.rs | 25 ++++++++++ docs/AUTHORING.md | 4 +- 4 files changed, 73 insertions(+), 30 deletions(-) diff --git a/crates/window-core/src/pipeline/hover.rs b/crates/window-core/src/pipeline/hover.rs index 50a93f93..b886c532 100644 --- a/crates/window-core/src/pipeline/hover.rs +++ b/crates/window-core/src/pipeline/hover.rs @@ -110,6 +110,30 @@ impl Shown<'_> { } } + /// Checks that its glyph fits the size limits and its origin the offset range, and returns the origin relative to + /// the center of a `gui`-sized GUI. `window` names the window in errors. + pub(super) fn check_limits(&self, window: &str, region: &RegionIr, gui: Size) -> Result<(i32, i32)> { + let (rect, size) = (self.rect, self.rect.size()); + let (what, fix) = match self.art { + Some(_) => ("hover art", "resize the art or set `hover` to false on it"), + None => ("hover outline", "set `hover` to false on it"), + }; + let err = |message: String| Error::Validation(format!("window `{window}`: {} {message}; {fix}", region.source)); + if !(MIN_WIDTH..=MAX_GLYPH_SIZE).contains(&size.width) || !(1..=MAX_HEIGHT).contains(&size.height) { + return Err(err(format!( + "has a {}x{} px {what}, which must be {MIN_WIDTH} to {MAX_GLYPH_SIZE} px wide and 1 to {MAX_HEIGHT} \ + px tall", + size.width, size.height + ))); + } + // The runtime sends each offset plus 128 as one color channel. + let (x, y) = (rect.x - (gui.width / 2) as i32, rect.y - (gui.height / 2) as i32); + if !(-128..=127).contains(&x) || !(-128..=127).contains(&y) { + return Err(err(format!("has a {what} that starts more than 128 px from the GUI center"))); + } + Ok((x, y)) + } + /// Its pixels, which cover `rect`. `window` names the window in errors. pub(super) fn texture(&self, window: &str, textures: &BTreeMap) -> Result { Ok(match self.art { @@ -137,26 +161,7 @@ impl CompileContext<'_> { let Some(shown) = Shown::of(region).filter(|_| self.hover) else { return Ok(None); }; - let (rect, size) = (shown.rect, shown.rect.size()); - let (what, fix) = match shown.art { - Some(_) => ("hover art", "resize the art or set `hover` to false on it"), - None => ("hover outline", "set `hover` to false on it"), - }; - let err = - |message: String| Error::Validation(format!("window `{}`: {} {message}; {fix}", w.name, region.source)); - if !(MIN_WIDTH..=MAX_GLYPH_SIZE).contains(&size.width) || !(1..=MAX_HEIGHT).contains(&size.height) { - return Err(err(format!( - "has a {}x{} px {what}, which must be {MIN_WIDTH} to {MAX_GLYPH_SIZE} px wide and 1 to {MAX_HEIGHT} \ - px tall", - size.width, size.height - ))); - } - // The runtime sends each offset plus 128 as one color channel. - let gui = w.surface.gui_size(); - let (x, y) = (rect.x - (gui.width / 2) as i32, rect.y - (gui.height / 2) as i32); - if !(-128..=127).contains(&x) || !(-128..=127).contains(&y) { - return Err(err(format!("has a {what} that starts more than 128 px from the GUI center"))); - } + let (x, y) = shown.check_limits(&w.name, region, w.surface.gui_size())?; let key = shown.glyph_key(); let glyph = char::from_u32(self.codepoints[&key]).expect("hover codepoint is a valid char"); @@ -168,7 +173,7 @@ impl CompileContext<'_> { self.push(Part::Hover, OutputFile::binary(path, texture.encode_png()?)); } // A bitmap glyph advances by its width plus 1; the data rows are opaque at both ends. - let text = format!("{glyph}{}", spacers_for(-(size.width as i32 + 1))); + let text = format!("{glyph}{}", spacers_for(-(shown.rect.width as i32 + 1))); Ok(Some(HoverEntry { text, x, y })) } diff --git a/crates/window-core/src/pipeline/preview.rs b/crates/window-core/src/pipeline/preview.rs index 082fee96..bdece74d 100644 --- a/crates/window-core/src/pipeline/preview.rs +++ b/crates/window-core/src/pipeline/preview.rs @@ -41,6 +41,8 @@ const MAX_SCALE: u32 = 16; /// The most pixels a finished preview image may hold, the area of 4096 x 4096. const MAX_PIXELS: u64 = 4096 * 4096; const ITEM_OUTLINE: [u8; 4] = [0xff, 0x40, 0xff, 0xc0]; +/// The text shader that draws hovers discards fragments with alpha below 0.1. +const HOVER_MIN_ALPHA: u8 = 26; const REGION_OUTLINE: [u8; 4] = [0x60, 0xff, 0x20, 0xc0]; /// Where the client draws the anvil's rename text inside the text field. const ANVIL_TEXT_INSET: Point = Point::new(3, 4); @@ -625,12 +627,15 @@ impl Scene<'_> { for placement in placements.iter().flatten() { placement.include_in(&mut bounds); } - let hovers: Vec> = if request.hovers && self.project.options.core_shaders.hover { - let regions = pane.regions.iter().filter(|region| hidden.shows(Member::Region, ®ion.name)); - regions.filter_map(Shown::of).collect() - } else { - Vec::new() - }; + let mut hovers: Vec> = Vec::new(); + if request.hovers && self.project.options.core_shaders.hover { + for region in pane.regions.iter().filter(|region| hidden.shows(Member::Region, ®ion.name)) { + if let Some(shown) = Shown::of(region) { + shown.check_limits(pane.name, region, pane.rect.size())?; + hovers.push(shown); + } + } + } for Shown { rect, .. } in &hovers { bounds.include(i64::from(rect.x), i64::from(rect.y), rect.width, rect.height); } @@ -696,7 +701,15 @@ impl Scene<'_> { self.painter.draw_line(&mut canvas, None, content, at, Rgb::new(0xff, 0xff, 0xff), Shadow::Vanilla, false); } for shown in &hovers { - canvas.blit(&shown.texture(pane.name, self.textures)?, shown.rect.origin()); + let texture = shown.texture(pane.name, self.textures)?; + for y in 0..texture.height { + for x in 0..texture.width { + let rgba = texture.get(x, y); + if rgba[3] >= HOVER_MIN_ALPHA { + canvas.blend(shown.rect.x + x as i32, shown.rect.y + y as i32, rgba); + } + } + } } if request.outlines { for region in pane.regions.iter().filter(|region| hidden.shows(Member::Region, ®ion.name)) { diff --git a/crates/window-core/src/pipeline/preview/tests.rs b/crates/window-core/src/pipeline/preview/tests.rs index 9998200c..fa91a9a0 100644 --- a/crates/window-core/src/pipeline/preview/tests.rs +++ b/crates/window-core/src/pipeline/preview/tests.rs @@ -432,3 +432,28 @@ fn hovers_draw_what_each_region_draws_in_the_pack_where_it_draws_it() { assert_ne!(plain.get(8, 18), white); assert_eq!(render(false, true).rgba, render(false, false).rgba, "nothing is drawn without `coreShaders.hover`"); } + +#[test] +fn hovers_skip_faint_pixels_and_obey_the_pack_limits() { + // Columns alternate between alpha 25 and 26. + let rgba = (0..16u8).flat_map(|i| [0xff, 0xff, 0xff, 25 + i % 2]).collect(); + let icon = Texture { width: 4, height: 4, rgba }; + let render = |art: Value| { + let hover = json!({ "art": art, "fit": "center", "tint": "#ff0000" }); + let region = json!({ "type": "region", "width": 16, "height": 16, "on_click": { "kind": "action", "id": "a" }, + "hover": hover }); + let children = json!([{ "type": "flex", "x": 8, "y": 18, "children": [region] }]); + let window = json!({ "name": "shop", "container": "generic_9x1", "children": children }); + let project = json!({ "windows": [window], "options": { "core_shaders": { "hover": true } } }); + let target = json!({ "kind": "window", "name": "shop" }); + let request = json!({ "name": "shot", "target": target, "hovers": true }); + render_with(project, request, vec![("icon.png", icon.clone())]) + }; + + let shot = render(json!({ "art": "texture", "texture": "icon.png" })).unwrap(); + assert_eq!(shot.get(14, 24)[3], 0, "the text shader discards alpha below 0.1"); + assert_eq!(shot.get(15, 24), [0xff, 0, 0, 26]); + let wide = json!({ "art": "texture", "texture": "icon.png", "width": 300, "height": 20 }); + let error = render(wide).unwrap_err().to_string(); + assert!(error.starts_with("window `shop`: ") && error.contains("300x20 px hover art"), "{error}"); +} diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index 50f00362..3b96352a 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -174,8 +174,8 @@ of several), a list of `[handle, value]` entries, and: pixel per GUI pixel. - `outlines`: outline the active [regions](#primitives), which otherwise draw nothing. - `hovers`: draw the [hover](#hover) of every active region at once, each where and as the pack draws it in game, - without the tooltip cut-out. A region that draws no hover in game draws none, and nothing is drawn unless the pack - sets `coreShaders.hover`. + without the tooltip cut-out. A region that draws no hover in game draws none, a hover outside the pack's size and + offset limits fails the preview, and nothing is drawn unless the pack sets `coreShaders.hover`. An image may hold at most 16,777,216 pixels (4096 x 4096) after scaling; a larger one fails the build naming the preview, its size, and its scale.