diff --git a/crates/window-core/src/authoring/art.rs b/crates/window-core/src/authoring/art.rs index 34232336..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 @@ -88,6 +89,87 @@ 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, 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 { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + 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 when it is centered. + pub(super) fn intern(&mut self, table: &mut Art) -> Result<()> { + match self { + Self::Outline(_) => Ok(()), + 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) + } + } + } +} + 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..ef1e63d6 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,15 @@ 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).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)?; } Ok(()) diff --git a/crates/window-core/src/authoring/element/build.rs b/crates/window-core/src/authoring/element/build.rs index 028acaa2..3c7ffbff 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, 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. @@ -200,10 +200,18 @@ 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, 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()?, 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..352575b4 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 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; [`Draw::Tinted`] when it is tinted. + 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..6a17c63a 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, HoverArt, HoverFit, SpriteDef, TextFit, TextStyle, Tint}; use crate::pipeline::texture_source_path; impl Solver<'_, T> { @@ -67,14 +67,40 @@ 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 } + 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 { + 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}`")))?; + (format!("frame/{name}"), self.frame_def_draw(name, def, rect, referenced_by)?) + } + 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); + (format!("image/{name}"), sprite_draw(def, Rect::new(x, y, size.width, size.height))) } - SpriteDef::Generated { style, .. } => Draw::Generated { style: style.clone(), dest: rect }, }; - self.emit_draw(draw, tint); - Ok(rect) + 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. @@ -155,6 +181,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 +201,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..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, 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 751ca350..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}; @@ -68,14 +68,42 @@ 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, + 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. #[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..b886c532 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, Rgb}; 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,67 +79,106 @@ 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. +pub(super) struct Shown<'a> { + /// Where it is drawn, in GUI space. + 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. + 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)) }), + 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, 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}"), + } + } + + /// 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 { + 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 outline size among `windows`. +/// 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) { - 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", - 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("starts more than 128 px from the GUI center, too far for a hover outline".into())); - } + let (x, y) = shown.check_limits(&w.name, region, w.surface.gui_size())?; - 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 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, 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. - let text = format!("{glyph}{}", spacers_for(-(size.width as i32 + 1))); + // A bitmap glyph advances by its width plus 1; the data rows are opaque at both ends. + let text = format!("{glyph}{}", spacers_for(-(shown.rect.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 +188,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 +225,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/src/pipeline/preview.rs b/crates/window-core/src/pipeline/preview.rs index bfe32538..bdece74d 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}; @@ -40,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); @@ -61,6 +64,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 +241,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 +265,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 +286,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 +627,18 @@ impl Scene<'_> { for placement in placements.iter().flatten() { placement.include_in(&mut bounds); } + 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); + } 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 +700,17 @@ 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 { + 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)) { 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..fa91a9a0 100644 --- a/crates/window-core/src/pipeline/preview/tests.rs +++ b/crates/window-core/src/pipeline/preview/tests.rs @@ -393,3 +393,67 @@ 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`"); +} + +#[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/crates/window-core/tests/hover.rs b/crates/window-core/tests/hover.rs index 5c552e03..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. @@ -81,7 +83,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 +115,121 @@ 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}"); +} + +/// 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_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("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(); + + // 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"); + 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, 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}" + ); + 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(); + 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); +} + +#[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 = 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 73ab9017..3b96352a 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). @@ -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, 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. @@ -388,32 +391,52 @@ 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 +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 + // the ring stretched over the button + // the same ring in gold + // art at its own size ``` -- 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, [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 - 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. 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 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/example/README.md b/example/README.md index 22d6d344..e4d5dd26 100644 --- a/example/README.md +++ b/example/README.md @@ -48,8 +48,9 @@ catalog filtered by the query, which the catalog's clear button resets and reope The workshop, under its own theme, sells three upgrades for the same coins: a click on a card buys the chosen amount of levels, each card shows its level as text and is marked new, shows a bar, or is marked maxed, and the rank badge takes -the shared tone. A maxed card takes no clicks, so hovering it shows "Fully upgraded" and no outline. Its levels last -until the server stops. +the shared tone. A card that can be bought draws a gold ring while hovered, the exit button a red one, both one white +texture tinted at build time, and the other controls the default white outline. A maxed card takes no clicks, so +hovering it shows "Fully upgraded" and no hover. Its levels last until the server stops. The actionbar-backed HUD demo is disabled by default so the inventory examples stay visually focused. Enable it with `-Dwindow.hud.enabled=true`; use `-Dwindow.hud.spriteDebug=true` to print each HUD's static art to chat. The pack blanks diff --git a/example/pack/src/window/art.ts b/example/pack/src/window/art.ts index f538f638..42aba906 100644 --- a/example/pack/src/window/art.ts +++ b/example/pack/src/window/art.ts @@ -1,4 +1,5 @@ import { derive, shape, texture } from "plugin:window/ui"; +import type { Hover } from "plugin:window/ui"; import { art } from "plugin:window/theme/industrial"; export const coin = shape( @@ -80,6 +81,15 @@ export const ranks = { rank_3: texture("window/icons/rank_3.png"), }; +/** A white ring, which a hover's tint colors exactly. */ +const ring = texture("window/hover/ring.png", { insets: 2 }); + +/** Hover art, stretched over the control: a gold ring for what can be bought and a red one for leaving. */ +export const hovers = { + ring: { art: ring, tint: "#ffd731" }, + exit: { art: ring, tint: "#ed171b" }, +} satisfies Record; + export const icons = { back: texture("window/icons/back.png"), clear: texture("window/icons/clear.png"), diff --git a/example/pack/src/window/hover/ring.png b/example/pack/src/window/hover/ring.png new file mode 100644 index 00000000..9e1a427f Binary files /dev/null and b/example/pack/src/window/hover/ring.png differ diff --git a/example/pack/src/window/index.ts b/example/pack/src/window/index.ts index 3e5219fb..d14a8cd3 100644 --- a/example/pack/src/window/index.ts +++ b/example/pack/src/window/index.ts @@ -55,6 +55,8 @@ export default defineWindows({ upgradesPreview({ scale: 3 }), // The same window with its regions outlined. upgradesPreview({ name: "upgrades_regions", outlines: true }), + // And with every region's hover drawn at once. + upgradesPreview({ name: "upgrades_hovers", scale: 3, hovers: true }), preview( topLeft, [ diff --git a/example/pack/src/window/upgrades.tsx b/example/pack/src/window/upgrades.tsx index b58b41b0..4a0eae40 100644 --- a/example/pack/src/window/upgrades.tsx +++ b/example/pack/src/window/upgrades.tsx @@ -29,7 +29,7 @@ import { colors, } from "plugin:window/theme/industrial"; -import { barTrack, coin, maxedPlate, toneFill } from "./art.ts"; +import { barTrack, coin, hovers, maxedPlate, toneFill } from "./art.ts"; import { UPGRADES, amount, @@ -84,8 +84,8 @@ function Cost(props: { index: number }) { } /** - * One face of upgrade `index`'s card: its name and level above `children`. `buy` makes the face take clicks, which - * also outlines it on hover; a face without it only shows its tooltip. + * One face of upgrade `index`'s card: its name and level above `children`. `buy` makes the face take clicks and + * draw a gold ring on hover; a face without it only shows its tooltip. */ function Face(props: { index: number; tooltip: string; buy?: boolean; children: Child }) { const i = props.index; @@ -95,7 +95,11 @@ function Face(props: { index: number; tooltip: string; buy?: boolean; children: {props.children} , - , + props.buy === true ? ( + + ) : ( + + ), ]; } @@ -181,7 +185,7 @@ export default ( Shop - 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..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 = "\uE35E\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 = "\uE35E\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 = "\uE35E\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 = "\uE35E\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 = "\uE35E\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 = "\uE35E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uE8EF\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = 28, y = -66, ), @@ -4368,7 +4368,7 @@ internal object WindowEntries { hitbox = null, source = "region `window:close`", hover = HoverEntry( - text = "\uF3F1\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", + text = "\uF14E\uDB80\uDC05\uDB80\uDC06\uDB80\uDC08\uDB80\uDC0A", x = 28, y = 59, ), diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HoverOutlines.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HoverOutlines.kt index b905a84d..9f0fc4a8 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HoverOutlines.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HoverOutlines.kt @@ -10,12 +10,12 @@ import net.kyori.adventure.text.format.ShadowColor import net.kyori.adventure.text.format.TextDecoration /** - * Builds the hitbox items of regions with a hover outline. + * Builds the hitbox items of regions that draw an outline or art while hovered. * - * The outline glyph leads the tooltip title, so the client draws it exactly while the item is hovered, and the + * The hover glyph leads the tooltip title, so the client draws it exactly while the item is hovered, and the * pack's core text shader moves its shadow onto the region. The shadow color tells the shader where: red and green - * are the region's offset from the GUI center plus 128, blue and the top 3 bits of alpha the width of the tooltip's - * text, and the low 5 bits of alpha its line count plus 1. The shader keeps the outline out of the tooltip box those + * are the hover's offset from the GUI center plus 128, blue and the top 3 bits of alpha the width of the tooltip's + * text, and the low 5 bits of alpha its line count plus 1. The shader keeps the hover out of the tooltip box those * describe. A tooltip whose size the server cannot know exactly, or that is larger than those fields hold, is encoded * with size zero, which makes no cut-out. */ @@ -28,7 +28,7 @@ internal class HoverOutlines( /** The pack's tooltip style with transparent sprites. */ private val hiddenStyle = Key.key(manifest.namespace, "hover") - /** The hitbox with [model] and [hover]'s outline; without a [tooltip] it shows no tooltip box. */ + /** The hitbox with [model] and [hover]'s glyph; without a [tooltip] it shows no tooltip box. */ fun hitbox( model: Key, hover: HoverEntry, 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..30a53705 100644 --- a/plugin/src/theme/industrial/controls.tsx +++ b/plugin/src/theme/industrial/controls.tsx @@ -8,7 +8,7 @@ import type { ClickAction, Condition, Selection, Toggle } from "../../bind/handl import type { Element } from "../../ui/document.ts"; import type { Child, DebugProps } from "../../ui/components.ts"; import type { BoxStyle, CaseStyle, ItemStyle, StyleValue } from "../../ui/styles.ts"; -import type { ArtRef, Tooltip } from "../../ui/types.ts"; +import type { ArtRef, Hover, Tooltip } from "../../ui/types.ts"; import { FACE_KEYS, face, flat, placement } from "./face.ts"; import type { ControlProps, FaceProps, Layer, StateProps, VariantStyle } from "./face.ts"; import { styles } from "./styles.ts"; @@ -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?: Hover; itemModel?: string; } diff --git a/plugin/src/theme/industrial/face.ts b/plugin/src/theme/industrial/face.ts index f1bdf131..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; @@ -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?: 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 afe8847c..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"; @@ -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?: 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 9d4dbb6b..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, @@ -138,10 +139,11 @@ 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, + * 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; + 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 018292a5..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, @@ -198,8 +199,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?: 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 f69183f8..132db47e 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 +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 -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/frame/art/87de09/34x16.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 -7463268324bd11e78433a6f619217932a92b4fdcbb3dccc75b989302839be517 dist/assets/window/window/debug.json +c10433f3bc07424fdee32b4becfb9c77af102f9100885102392a4d1a462cc3d9 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 +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 0d30e032..8184eb75 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,16 @@ export default ( + {/* 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 ade3af65..ebb6f2ad 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 = { 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), + [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..2a4b5833 100644 --- a/plugin/test/jsx.test.ts +++ b/plugin/test/jsx.test.ts @@ -82,6 +82,10 @@ 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); + 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 635a335b..b5a99655 100644 --- a/plugin/test/jsx.typecheck.tsx +++ b/plugin/test/jsx.typecheck.tsx @@ -128,9 +128,20 @@ const setterCondition = Switch({ bind: category.set("all"), children: [] }); const comparedClick = ; const quietRegion = ; const quietButton =