From e46d41421a4f9e476ec1b7efcef94b91257511c3 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:51:08 -0400 Subject: [PATCH 1/6] feat(core): compile bars into power-of-two fill clips A bar's fill is an art layer cut into clips of 1, 2, 4, ... columns, each a glyph, so the runtime draws any column count from one clip per set bit. Clips with the same pixels share a glyph. Adds the fraction handle kind, the bar manifest entry, Kotlin codegen, and preview state. --- crates/window-core/src/authoring/element.rs | 8 + .../src/authoring/element/build.rs | 26 ++- .../src/authoring/element/fields.rs | 2 + .../src/authoring/element/handles.rs | 12 +- crates/window-core/src/codegen/definitions.rs | 2 + crates/window-core/src/codegen/entries.rs | 16 +- .../window-core/src/codegen/view/handles.rs | 6 + crates/window-core/src/debug/surfaces.rs | 7 +- crates/window-core/src/hud/tests.rs | 2 +- crates/window-core/src/ir.rs | 20 +- crates/window-core/src/layout/flex.rs | 2 + crates/window-core/src/layout/measure.rs | 1 + crates/window-core/src/layout/mod.rs | 3 + crates/window-core/src/layout/visuals.rs | 39 +++- crates/window-core/src/manifest.rs | 54 ++++- crates/window-core/src/model.rs | 22 ++ crates/window-core/src/pipeline.rs | 1 + crates/window-core/src/pipeline/anvil.rs | 3 +- crates/window-core/src/pipeline/bars.rs | 104 +++++++++ crates/window-core/src/pipeline/glyphs.rs | 10 +- crates/window-core/src/pipeline/preview.rs | 31 ++- .../window-core/src/pipeline/preview/tests.rs | 18 ++ crates/window-core/src/pipeline/switches.rs | 17 +- crates/window-core/src/pipeline/tests/huds.rs | 2 +- crates/window-core/tests/bars.rs | 202 ++++++++++++++++++ 25 files changed, 580 insertions(+), 30 deletions(-) create mode 100644 crates/window-core/src/pipeline/bars.rs create mode 100644 crates/window-core/tests/bars.rs diff --git a/crates/window-core/src/authoring/element.rs b/crates/window-core/src/authoring/element.rs index e092ac0..c62bd6e 100644 --- a/crates/window-core/src/authoring/element.rs +++ b/crates/window-core/src/authoring/element.rs @@ -26,6 +26,8 @@ pub(super) struct ElementDto { name: Option, frame: Option, art: Option, + fill: Option, + track: Option, text: Option, value: Option, initial: Option, @@ -74,6 +76,8 @@ struct ElementShapeDto { kind: String, frame: Option, art: Option, + fill: Option, + track: Option, text: Option, value: Option, initial: Option, @@ -144,6 +148,8 @@ impl ElementDto { name: None, frame: shape.frame, art: shape.art, + fill: shape.fill, + track: shape.track, text: shape.text, value: shape.value, initial: shape.initial, @@ -191,6 +197,8 @@ impl ElementDto { pub(super) fn intern_art(children: &mut [ElementDto], table: &mut Art) -> Result<()> { for child in children { intern(table, &mut child.frame, ArtUse::Frame)?; + intern(table, &mut child.fill, ArtUse::Frame)?; + intern(table, &mut child.track, ArtUse::Frame)?; intern(table, &mut child.art, ArtUse::Image)?; intern(table, &mut child.selected_sprite, ArtUse::Sprite)?; 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 a87ac3f..836fc8f 100644 --- a/crates/window-core/src/authoring/element/build.rs +++ b/crates/window-core/src/authoring/element/build.rs @@ -3,9 +3,12 @@ use crate::authoring::art::ArtRefDto; use crate::authoring::flex::parse_auto_flow; use crate::authoring::parse::validate_name; use crate::inventory::{InventorySlotSection, SlotRectClaim}; -use crate::model::{Element, FlexBox, Region, SlotSection, Switch, SwitchCase}; +use crate::model::{Bar, Element, FlexBox, 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. +const MAX_BAR_SIZE: u32 = 256; + impl ElementDto { pub(in crate::authoring) fn into_element(self) -> Result { let debug_name = self.debug_name.clone(); @@ -17,6 +20,7 @@ impl ElementDto { match self.kind.as_str() { "sprite" => self.build_sprite(), "sprite_slot" => self.build_sprite_slot(), + "bar" => self.build_bar(), "item" => self.build_item(), "collection" => self.build_collection(), "anvil_input" => self.build_anvil_input(), @@ -51,6 +55,26 @@ impl ElementDto { }) } + fn build_bar(self) -> Result { + let size = self.size()?; + if !(1..=MAX_BAR_SIZE).contains(&size.width) || !(1..=MAX_BAR_SIZE).contains(&size.height) { + return Err(Error::Validation(format!( + "bar element is {}x{} px; its `width` and `height` must each be from 1 to {MAX_BAR_SIZE}", + size.width, size.height + ))); + } + let fill = art_name(&self.fill)?.ok_or_else(|| Error::Validation("bar element requires `fill`".into()))?; + Ok(Element::Bar(Box::new(Bar { + name: self.entry()?, + size, + pos: self.pos()?, + fill, + track: art_name(&self.track)?, + tint: self.tint()?, + debug_name: self.debug_name, + }))) + } + fn build_item(self) -> Result { Ok(Element::Item { name: self.entry()?, diff --git a/crates/window-core/src/authoring/element/fields.rs b/crates/window-core/src/authoring/element/fields.rs index 73e4b1e..b537140 100644 --- a/crates/window-core/src/authoring/element/fields.rs +++ b/crates/window-core/src/authoring/element/fields.rs @@ -10,6 +10,7 @@ use crate::{Error, Result, text_font}; const SPRITE_FIELDS: &[&str] = &["type", "art", "tint", "x", "y", "debug_name"]; const SPRITE_SLOT_FIELDS: &[&str] = &["type", "handle", "tint", "x", "y", "width", "height", "align", "debug_name"]; +const BAR_FIELDS: &[&str] = &["type", "handle", "fill", "track", "tint", "x", "y", "width", "height", "debug_name"]; const ITEM_FIELDS: &[&str] = &["type", "handle", "slots", "pattern", "transform", "debug_name"]; const COLLECTION_FIELDS: &[&str] = &["type", "handle", "frame", "selected_sprite", "slots", "pattern", "transform", "action", "debug_name"]; @@ -66,6 +67,7 @@ fn allowed_fields(kind: &str) -> Option<&'static [&'static str]> { Some(match kind { "sprite" => SPRITE_FIELDS, "sprite_slot" => SPRITE_SLOT_FIELDS, + "bar" => BAR_FIELDS, "item" => ITEM_FIELDS, "collection" => COLLECTION_FIELDS, "anvil_input" => ANVIL_INPUT_FIELDS, diff --git a/crates/window-core/src/authoring/element/handles.rs b/crates/window-core/src/authoring/element/handles.rs index a8880fe..ff0305f 100644 --- a/crates/window-core/src/authoring/element/handles.rs +++ b/crates/window-core/src/authoring/element/handles.rs @@ -80,6 +80,7 @@ impl Collector<'_> { let role = match dto.kind.as_str() { "slot" => Some(HandleRole::Slot), "sprite_slot" => Some(HandleRole::SpriteSlot), + "bar" => Some(HandleRole::Bar), "item" => Some(HandleRole::Item), "collection" => Some(HandleRole::Collection), "anvil_input" => Some(HandleRole::Input), @@ -103,7 +104,8 @@ impl Collector<'_> { Ok(()) } - /// Names an element that binds one handle: a text, sprite, or item slot, a collection, an input, or a switch. + /// Names an element that binds one handle: a text, sprite, or item slot, a bar, a collection, an input, or a + /// switch. fn primary(&mut self, dto: &mut ElementDto, role: HandleRole, handle: &HandleRefDto) -> Result<()> { let kind = self.declare(handle, role)?; if role == HandleRole::Collection && dto.selected_sprite.is_some() && !handle.selectable { @@ -216,11 +218,12 @@ impl Collector<'_> { | HandleKind::Sprite | HandleKind::Items | HandleKind::Action + | HandleKind::Fraction ); if !handle.shape.is_empty() && (!indexable || handle.shape.len() > 2 || handle.shape.contains(&0)) { return Err(self.err(format!( - "{} `{id}` has an invalid shape; flags, values, text, sprites, items, and actions take `[n]` or \ - `[rows, columns]` of positive sizes", + "{} `{id}` has an invalid shape; flags, values, text, sprites, items, actions, and fractions take \ + `[n]` or `[rows, columns]` of positive sizes", kind.id() ))); } @@ -357,6 +360,7 @@ fn parse_kind(kind: &str) -> Option { "input" => HandleKind::Input, "builtin" => HandleKind::Builtin, "tint" => HandleKind::Tint, + "fraction" => HandleKind::Fraction, _ => return None, }) } @@ -372,6 +376,7 @@ fn role_accepts(role: HandleRole, kind: HandleKind) -> bool { HandleRole::Switch => matches!(kind, K::Flag | K::Toggle | K::Value | K::Selection), HandleRole::Click => matches!(kind, K::Action | K::Builtin | K::Selection | K::Toggle), HandleRole::Tint => kind == K::Tint, + HandleRole::Bar => kind == K::Fraction, } } @@ -385,5 +390,6 @@ fn role_noun(role: HandleRole) -> &'static str { HandleRole::Switch => "a switch or show condition", HandleRole::Click => "a click", HandleRole::Tint => "a tint", + HandleRole::Bar => "a bar's fill", } } diff --git a/crates/window-core/src/codegen/definitions.rs b/crates/window-core/src/codegen/definitions.rs index 64852b8..781cfb2 100644 --- a/crates/window-core/src/codegen/definitions.rs +++ b/crates/window-core/src/codegen/definitions.rs @@ -89,6 +89,7 @@ fn window_imports(manifest: &Manifest) -> Vec<&'static str> { (any(&|w| !w.collections.is_empty()), "CollectionEntry"), (any(&|w| !w.inputs.is_empty()), "AnvilInputEntry"), (any(&|w| !w.art.is_empty()), "ArtEntry"), + (any(&|w| w.art.values().any(|art| art.bar.is_some())), "BarEntry"), (has_hitbox, "HitboxEntry"), (any(&|w| !w.items.is_empty()), "ItemEntry"), (has_layers, "LayerEntry"), @@ -149,6 +150,7 @@ pub(super) fn generate_hud_entries(manifest: &Manifest, package_name: &str) -> O let imports = [ (true, "Align"), (manifest.huds.values().any(|hud| !hud.art.is_empty()), "ArtEntry"), + (manifest.huds.values().any(|hud| hud.art.values().any(|art| art.bar.is_some())), "BarEntry"), (true, "HudEntry"), (true, "HudShaderEntry"), (true, "HudSurfaceEntry"), diff --git a/crates/window-core/src/codegen/entries.rs b/crates/window-core/src/codegen/entries.rs index bfd375f..aec496d 100644 --- a/crates/window-core/src/codegen/entries.rs +++ b/crates/window-core/src/codegen/entries.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use crate::ir::{Hitbox, Layer, Tooltip}; use crate::manifest::{ - AnvilInputEntry, ArtEntry, CollectionEntry, FontMetricsEntry, HudShaderEntry, HudSurfaceEntry, ItemEntry, + AnvilInputEntry, ArtEntry, BarEntry, CollectionEntry, FontMetricsEntry, HudShaderEntry, HudSurfaceEntry, ItemEntry, RegionEntry, SlotAreaEntry, SlotEntry, SlotLinesEntry, SlotRefEntry, SpriteEntry, SpriteSlotEntry, SurfaceEntry, SwitchCaseEntry, SwitchEntry, TextOverflow, }; @@ -113,9 +113,23 @@ pub(super) fn sprite_slot_entry_expr(slot: &SpriteSlotEntry, level: usize) -> St pub(super) fn art_entry_expr(art: &ArtEntry, level: usize) -> String { let call = Call::new("ArtEntry", level).arg("static", kt_string(&art.static_text)); + let call = match &art.bar { + Some(bar) => call.arg("bar", bar_entry_expr(bar, level + 1)), + None => call, + }; with_tint(call, art.tint.as_ref(), &art.tint_markers, level).finish() } +fn bar_entry_expr(bar: &BarEntry, level: usize) -> String { + Call::new("BarEntry", level) + .arg("name", kt_string(&bar.name)) + .arg("x", bar.x) + .arg("width", bar.width) + .arg("levels", string_list_expr(&bar.levels)) + .arg("advances", char_map(&bar.advances, level + 1)) + .finish() +} + /// A `mapOf` of strings. pub(super) fn strings_map(values: &BTreeMap, level: usize) -> String { string_map(values, level, |value, _| kt_string(value)) diff --git a/crates/window-core/src/codegen/view/handles.rs b/crates/window-core/src/codegen/view/handles.rs index 8f9734c..56f54ed 100644 --- a/crates/window-core/src/codegen/view/handles.rs +++ b/crates/window-core/src/codegen/view/handles.rs @@ -210,6 +210,11 @@ impl HandleMember { format!("The palette entry the `{id}` tint draws in."), format!("{member}(): {enum_name}"), ), + HandleKind::Fraction => abstract_fun( + w, + format!("How much of the `{id}` bar is filled, from 0 to 1."), + format!("{member}({params}): Float"), + ), HandleKind::Builtin => {} } } @@ -331,6 +336,7 @@ fn bind_use(w: &mut KotlinWriter, handle: &HandleMember, use_: &HandleUse, args: w.line(format_args!("switch({entry}) {{ {value} }}")); } HandleRole::Tint => w.line(format_args!("tint({entry}) {{ {member}().value }}")), + HandleRole::Bar => w.line(format_args!("bar({entry}) {{ {member}({args}) }}")), // A runtime action cannot be bound; the runtime runs it. HandleRole::Click if handle.handle.kind == HandleKind::Builtin => {} HandleRole::Click => Handler::of(handle, use_, args).call(w, &format!("button({entry}")), diff --git a/crates/window-core/src/debug/surfaces.rs b/crates/window-core/src/debug/surfaces.rs index 0408a45..0cf20da 100644 --- a/crates/window-core/src/debug/surfaces.rs +++ b/crates/window-core/src/debug/surfaces.rs @@ -125,7 +125,12 @@ fn tree_layers( Layer::Art(name) => { let art = entries.art.get(name); let layer = art.and_then(|art| static_layer(manifest, &art.static_text, origin, glyphs, Some(0))); - if let Some(layer) = layer { + if let Some(mut layer) = layer { + // A bar's first glyph is one clip of its fill; the layer covers the whole fill. + if let Some(bar) = art.and_then(|art| art.bar.as_ref()) { + layer.bounds[0] = origin[0] + bar.x; + layer.bounds[2] = bar.width as i32; + } out.push(DebugLayer { id: format!("art:{name}"), kind: "art_chrome".into(), ..layer }); } } diff --git a/crates/window-core/src/hud/tests.rs b/crates/window-core/src/hud/tests.rs index cd9e3a0..67e9f21 100644 --- a/crates/window-core/src/hud/tests.rs +++ b/crates/window-core/src/hud/tests.rs @@ -119,7 +119,7 @@ fn segment_markers_are_green_zero_unique_and_stable() { fn tinted_segments_get_a_marker_per_palette_entry_drawn_in_its_color() { let mut hud = shader_hud("status", "coins"); hud.slots[0].tint = Some("tone".into()); - hud.art.push(ArtIr { name: "art/0".into(), draws: Vec::new(), tint: Some("tone".into()) }); + hud.art.push(ArtIr { name: "art/0".into(), draws: Vec::new(), tint: Some("tone".into()), bar: None }); let tone = Handle { kind: HandleKind::Tint, values: vec!["ok".into(), "warn".into()], diff --git a/crates/window-core/src/ir.rs b/crates/window-core/src/ir.rs index 4b35e83..52cad5e 100644 --- a/crates/window-core/src/ir.rs +++ b/crates/window-core/src/ir.rs @@ -256,6 +256,8 @@ pub enum HandleKind { Builtin, /// One of `values`, computed by Kotlin, naming the palette color tinted art and text take. Tint, + /// A number from 0 to 1, computed by Kotlin: how much of a bar is filled. + Fraction, } impl HandleKind { @@ -274,6 +276,7 @@ impl HandleKind { Self::Input => "input", Self::Builtin => "builtin", Self::Tint => "tint", + Self::Fraction => "fraction", } } } @@ -298,6 +301,8 @@ pub enum HandleRole { Click, /// Colors tinted art and text. Tint, + /// Fills a bar. + Bar, } /// One element's use of a handle, bound to the manifest entry `entry`. @@ -460,8 +465,8 @@ pub struct AnvilInputIr { pub item_model: Option, } -/// Art the runtime draws as its own glyph, at its place among the runtime layers: tinted art, and the untinted art -/// authored over it. +/// Art the runtime draws as its own glyphs, at its place among the runtime layers: tinted art, the untinted art +/// authored over it, and the fill of a bar. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ArtIr { /// Unique name within the surface, `art/N` in document order. @@ -470,6 +475,15 @@ pub struct ArtIr { pub draws: Vec, /// The tint handle whose current palette color the glyph is multiplied by; `None` draws it as authored. pub tint: Option, + /// The entry whose fraction decides how many of the art's columns are drawn, counted from its left edge; + /// `None` draws them all. + pub bar: Option, +} + +/// The columns a bar of `width` columns draws at `fraction`: the nearest whole column, with `fraction` clamped to +/// 0 through 1. +pub fn bar_columns(fraction: f64, width: u32) -> u32 { + (fraction.clamp(0.0, 1.0) * f64::from(width)).round() as u32 } /// A runtime-selected group of cases, of which at most one is active. @@ -518,7 +532,7 @@ pub enum Layer { Switch(String), /// A collection's selected-cell sprite. Collection(String), - /// An art layer: tinted art, or untinted art authored over it. + /// An art layer: tinted art, untinted art authored over it, or the fill of a bar. Art(String), } diff --git a/crates/window-core/src/layout/flex.rs b/crates/window-core/src/layout/flex.rs index 354c1da..0b5fb01 100644 --- a/crates/window-core/src/layout/flex.rs +++ b/crates/window-core/src/layout/flex.rs @@ -225,6 +225,7 @@ impl Solver<'_, T> { | Element::Slot { .. } | Element::Sprite { .. } | Element::SpriteSlot { .. } + | Element::Bar(_) | Element::Region(_) => self.measure(el), other => Err(self.target.layout_err(format!( "{} cannot be laid out inside a flex box; give it a slot source or place it in a section", @@ -685,6 +686,7 @@ fn describe(el: &Element) -> String { Element::Slot { name, .. } => format!("slot `{name}`"), Element::SpriteSlot { name, .. } => format!("sprite slot `{name}`"), Element::Sprite { name, .. } => format!("image `{name}`"), + Element::Bar(bar) => format!("bar `{}`", bar.name), Element::Label { text, .. } => format!("label `{text}`"), Element::Section(section) => format!("{} section", section_name(section)), Element::Flex(_) => "flex box".into(), diff --git a/crates/window-core/src/layout/measure.rs b/crates/window-core/src/layout/measure.rs index d8fcaf0..c352330 100644 --- a/crates/window-core/src/layout/measure.rs +++ b/crates/window-core/src/layout/measure.rs @@ -18,6 +18,7 @@ impl<'a, T: LayoutTarget> Solver<'a, T> { self.require_sprite_slots(name)?; Ok(*size) } + Element::Bar(bar) => Ok(bar.size), Element::Label { text, width, style, .. } => { let w = match width { Some(width) => *width, diff --git a/crates/window-core/src/layout/mod.rs b/crates/window-core/src/layout/mod.rs index a7b208d..9c51746 100644 --- a/crates/window-core/src/layout/mod.rs +++ b/crates/window-core/src/layout/mod.rs @@ -166,6 +166,7 @@ fn pos_of(el: &Element) -> Option { | Element::SpriteSlot { pos, .. } | Element::Label { pos, .. } | Element::Slot { pos, .. } => *pos, + Element::Bar(bar) => bar.pos, Element::Flex(node) => node.pos, Element::Switch(switch) => switch.pos, Element::Item { .. } @@ -186,6 +187,7 @@ fn debug_name_of(el: &Element) -> Option<&str> { | Element::AnvilInput { debug_name, .. } | Element::Label { debug_name, .. } | Element::Slot { debug_name, .. } => debug_name.as_deref(), + Element::Bar(bar) => bar.debug_name.as_deref(), Element::Region(region) => region.debug_name.as_deref(), Element::Flex(node) => node.debug_name.as_deref(), Element::Section(section) => section.debug_name.as_deref(), @@ -371,6 +373,7 @@ impl<'a, T: LayoutTarget> Solver<'a, T> { Element::SpriteSlot { name, size, align, tint, .. } => { self.place_sprite_slot(name, Rect::from_parts(origin, *size), *align, tint.clone()) } + Element::Bar(bar) => self.place_bar(bar, Rect::from_parts(origin, bar.size)), Element::Label { text, width, style, .. } => self.place_label(text, *width, style, origin), Element::Slot { name, width, style, fit, .. } => self.place_slot(name, *width, style, *fit, origin), Element::Flex(node) => self.place_flex(node, origin, None), diff --git a/crates/window-core/src/layout/visuals.rs b/crates/window-core/src/layout/visuals.rs index 6cbf681..4d19651 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, Layer, SlotIr, SpriteSlotIr, TextureKey}; -use crate::model::{Frame, SpriteDef, TextFit, TextStyle, Tint}; +use crate::model::{Bar, Frame, SpriteDef, TextFit, TextStyle, Tint}; use crate::pipeline::texture_source_path; impl Solver<'_, T> { @@ -100,7 +100,7 @@ impl Solver<'_, T> { let index = open.unwrap_or_else(|| { let name = format!("art/{}", self.art.len()); self.layers.push(Layer::Art(name.clone())); - self.art.push(ArtIr { name, draws: Vec::new(), tint: palette.clone() }); + self.art.push(ArtIr { name, draws: Vec::new(), tint: palette.clone(), bar: None }); self.art.len() - 1 }); self.art[index].draws.push(draw); @@ -114,6 +114,28 @@ impl Solver<'_, T> { } } + /// Places bar `name` over `rect`: its track in the art underneath, and its fill as an art layer of its own + /// that the runtime draws up to the bar's fraction. + pub(super) fn place_bar(&mut self, bar: &Bar, rect: Rect) -> Result { + let name = &bar.name; + self.register_name(name)?; + if let Some(track) = &bar.track { + self.emit_frame(track, rect, None, &format!("bar `{name}` track"))?; + } + let draw = self.frame_draw(&bar.fill, rect, &format!("bar `{name}` fill"))?; + let (draw, tint) = match &bar.tint { + Some(Tint::Color(color)) => (Draw::Tinted { draw: Box::new(draw), color: *color }, None), + Some(Tint::Palette(id)) => (draw, Some(id.clone())), + None => (draw, None), + }; + let art = format!("art/{}", self.art.len()); + self.layers.push(Layer::Art(art.clone())); + self.art.push(ArtIr { name: art, draws: vec![draw], tint, bar: Some(name.clone()) }); + self.art_run.covered.push(rect); + self.art_run.untinted = None; + Ok(rect.size()) + } + /// Draws `frame` stretched over `dest`, after checking the frame and warning about overflow. pub(super) fn emit_frame( &mut self, @@ -122,11 +144,18 @@ impl Solver<'_, T> { tint: Option<&Tint>, referenced_by: &str, ) -> Result<()> { + let draw = self.frame_draw(frame, dest, referenced_by)?; + self.emit_draw(draw, tint); + Ok(()) + } + + /// `frame` stretched over `dest`, after checking the frame and warning about overflow. + fn frame_draw(&mut self, frame: &str, dest: Rect, referenced_by: &str) -> Result { let project = self.project; 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); - let draw = match def { + Ok(match def { Frame::Texture { texture, insets } => { let tex_size = self.intrinsic_texture_size(texture, referenced_by)?; let min_w = insets.left + insets.right + 1; @@ -137,9 +166,7 @@ impl Solver<'_, T> { Draw::NineSlice { texture: TextureKey(texture_source_path(texture)), insets: *insets, dest } } Frame::Generated(style) => Draw::Generated { style: style.clone(), dest }, - }; - self.emit_draw(draw, tint); - Ok(()) + }) } } diff --git a/crates/window-core/src/manifest.rs b/crates/window-core/src/manifest.rs index 2d04771..d733974 100644 --- a/crates/window-core/src/manifest.rs +++ b/crates/window-core/src/manifest.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::font::spacers_for; use crate::inventory::{InventorySlotArea, InventorySlotRef}; use crate::ir::{Align, Handle, Hitbox, Layer}; use crate::{Error, Result}; @@ -110,12 +111,17 @@ pub struct WindowEntry { pub handles: BTreeMap, } -/// Art the runtime draws as its own glyph: tinted art, or the untinted art authored over it. +/// Art the runtime draws as its own glyphs: tinted art, the untinted art authored over it, or the fill of a bar. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ArtEntry { - /// Net-zero baked art in [`Manifest::font`], starting and ending where a switch case's does. + /// Net-zero baked art in [`Manifest::font`], starting and ending where a switch case's does. A bar's is its + /// whole fill. #[serde(rename = "static")] pub static_text: String, + /// Set when the art is the fill of a bar, which the runtime draws up to a fraction instead of + /// [`Self::static_text`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bar: Option, /// The tint whose current palette color the art is multiplied by: the runtime sends the glyph in that color. /// Absent art is sent white. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -125,6 +131,50 @@ pub struct ArtEntry { pub tint_markers: BTreeMap, } +/// The fill of a bar: art of which the runtime draws the leftmost columns, up to a fraction of [`Self::width`]. +/// +/// The fill is cut into clips of 1, 2, 4, … columns, a clip of `2^k` columns starting at each multiple of +/// `2^(k + 1)`. Any column count is then one clip per set bit of the count, laid left to right from the largest, +/// and each clip draws the columns of the art at its own place. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BarEntry { + /// The entry the runtime binds the fraction to. + pub name: String, + /// The fill's left edge, in pixels right of the cursor origin that art segments start and end at. + pub x: i32, + /// The fill's width in columns: what a fraction of 1 draws. + pub width: u32, + /// `levels[k]` holds one glyph of [`Manifest::font`] per clip of `2^k` columns: its character `i` draws the + /// columns from `i << (k + 1)`, and a space stands for a clip with nothing to draw. + pub levels: Vec, + /// The cursor advance of each glyph in [`Self::levels`]. + pub advances: BTreeMap, +} + +impl BarEntry { + /// The net-zero text that draws the leftmost `columns` columns of the fill. + pub fn compose(&self, columns: u32) -> String { + let columns = columns.min(self.width); + let (mut text, mut cursor, mut start) = (String::new(), 0, 0); + for (level, glyphs) in self.levels.iter().enumerate().rev() { + let size = 1 << level; + if columns & size == 0 { + continue; + } + let glyph = glyphs.chars().nth((start >> (level + 1)) as usize).filter(|glyph| *glyph != ' '); + if let Some(glyph) = glyph { + let at = self.x + start as i32; + text.push_str(&spacers_for(at - cursor)); + text.push(glyph); + cursor = at + self.advances[&glyph] as i32; + } + start += size; + } + text.push_str(&spacers_for(-cursor)); + text + } +} + /// Cases of which the runtime draws and claims only the one its binding names. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SwitchEntry { diff --git a/crates/window-core/src/model.rs b/crates/window-core/src/model.rs index 668e77e..c6b4def 100644 --- a/crates/window-core/src/model.rs +++ b/crates/window-core/src/model.rs @@ -191,6 +191,26 @@ impl Default for TextFit { } } +/// A fill bar: `fill` art stretched over the bar, of which the runtime draws the columns up to a fraction, counted +/// from the left edge. +#[derive(Clone, Debug)] +pub struct Bar { + /// Bar entry name. + pub name: String, + /// The size of the bar at a fraction of 1. + pub size: Size, + /// Explicit position relative to the parent content origin. + pub pos: Option, + /// Interned frame the fill is clipped from. + pub fill: String, + /// Interned frame drawn whole under the fill. + pub track: Option, + /// The color the fill is multiplied by. + pub tint: Option, + /// Authored name shown in errors and diagnostics. + pub debug_name: Option, +} + /// An element of the authored tree. #[derive(Clone, Debug)] pub enum Element { @@ -221,6 +241,8 @@ pub enum Element { /// Authored name shown in errors and diagnostics. debug_name: Option, }, + /// A fill bar. + Bar(Box), /// A dynamic inventory item region with no click handler. Item { /// Item control name. diff --git a/crates/window-core/src/pipeline.rs b/crates/window-core/src/pipeline.rs index 204cb98..9616034 100644 --- a/crates/window-core/src/pipeline.rs +++ b/crates/window-core/src/pipeline.rs @@ -21,6 +21,7 @@ use crate::text_font::TextFonts; use crate::{Error, Result, text_font, vanilla}; mod anvil; +mod bars; mod fonts; mod glyphs; mod hud; diff --git a/crates/window-core/src/pipeline/anvil.rs b/crates/window-core/src/pipeline/anvil.rs index e385dbe..b15196b 100644 --- a/crates/window-core/src/pipeline/anvil.rs +++ b/crates/window-core/src/pipeline/anvil.rs @@ -120,7 +120,8 @@ pub(super) fn check_title(w: &LaidOutWindow, experimental: bool, warnings: &mut .chain(sprites) .chain(collections.map(|collection| &collection.name)) .chain(drawing.map(|switch| &switch.name)) - .chain(tints); + .chain(tints) + .chain(w.art.iter().filter_map(|art| art.bar.as_ref())); for name in updates { if !experimental { return Err(Error::Validation(format!( diff --git a/crates/window-core/src/pipeline/bars.rs b/crates/window-core/src/pipeline/bars.rs new file mode 100644 index 0000000..522de2a --- /dev/null +++ b/crates/window-core/src/pipeline/bars.rs @@ -0,0 +1,104 @@ +//! Bar fills: an art layer cut into power-of-two clips, each a glyph, that the runtime lays side by side to draw any +//! number of the fill's columns. Clips with the same pixels share a glyph, so a fill whose columns repeat costs one +//! glyph per clip width. + +use std::collections::{BTreeMap, HashMap}; + +use crate::Result; +use crate::compose::{Composite, Texture}; +use crate::geometry::{Point, Rect}; +use crate::manifest::BarEntry; +use crate::size::{Pane, Part}; + +use super::CompileContext; + +/// The columns `start..start + width` of `texture`. +pub(super) fn columns(texture: &Texture, start: u32, width: u32) -> Texture { + let mut out = Texture::transparent(width, texture.height); + let row = width as usize * 4; + for y in 0..texture.height as usize { + let from = (y * texture.width as usize + start as usize) * 4; + out.rgba[y * row..(y + 1) * row].copy_from_slice(&texture.rgba[from..from + row]); + } + out +} + +/// The clips of `fill`: `levels[k][i]` is the clip of `2^k` columns starting at column `i << (k + 1)`, as the index +/// of the first clip of its level with the same pixels, or `None` when it is fully transparent. +fn clip_levels(fill: &Texture) -> Vec>> { + let mut levels = Vec::new(); + let mut size = 1; + while size <= fill.width { + let mut first: HashMap, usize> = HashMap::new(); + let starts = (0..=fill.width - size).step_by(2 * size as usize); + let clips = starts.enumerate().map(|(index, start)| { + let clip = columns(fill, start, size); + let drawn = clip.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[3] != 0); + drawn.then(|| *first.entry(clip.rgba).or_insert(index)) + }); + levels.push(clips.collect()); + size *= 2; + } + levels +} + +/// The codepoint allocation key of the clip at `index` of `level`, under the key of its art layer. +fn clip_key(art_key: &str, level: usize, index: usize) -> String { + format!("{art_key}/{level}/{index}") +} + +/// The codepoint allocation keys of the glyphs of bar fill `fill`, under the key of its art layer. +pub(super) fn clip_keys(art_key: &str, fill: &Texture) -> Vec { + let levels = clip_levels(fill); + let distinct = levels.iter().enumerate().flat_map(|(level, clips)| { + clips.iter().enumerate().filter(|&(index, clip)| *clip == Some(index)).map(move |(index, _)| (level, index)) + }); + distinct.map(|(level, index)| clip_key(art_key, level, index)).collect() +} + +impl CompileContext<'_> { + /// Emits a glyph per distinct clip of bar `name`'s fill `comp`, and returns how the runtime composes them from + /// `origin`. `art_key` and `file_base` name the fill's art layer among the glyph keys and textures. + pub(super) fn bar_entry( + &mut self, + name: &str, + comp: &Composite, + pane: Pane<'_>, + art_key: &str, + file_base: &str, + origin: Point, + ) -> Result { + let ascent = origin.y + 7 - comp.bounds.y; + let mut entry = BarEntry { + name: name.into(), + x: comp.bounds.x - origin.x, + width: comp.texture.width, + levels: Vec::new(), + advances: BTreeMap::new(), + }; + for (level, clips) in clip_levels(&comp.texture).iter().enumerate() { + let size = 1 << level; + let mut glyphs: Vec = Vec::with_capacity(clips.len()); + for (index, clip) in clips.iter().enumerate() { + let glyph = match *clip { + None => ' ', + Some(first) if first < index => glyphs[first], + Some(_) => { + let start = (index as u32) << (level + 1); + let bounds = Rect::new(comp.bounds.x + start as i32, comp.bounds.y, size, comp.bounds.height); + let clip = + Composite { texture: columns(&comp.texture, start, size), bounds, has_content: true }; + let file = format!("{file_base}_{level}_{index}"); + let key = clip_key(art_key, level, index); + let glyph = self.emit_static_glyph(&clip, ascent, pane, &file, key, Part::art_layer(pane))?; + entry.advances.insert(glyph.glyph, glyph.advance); + glyph.glyph + } + }; + glyphs.push(glyph); + } + entry.levels.push(glyphs.into_iter().collect()); + } + Ok(entry) + } +} diff --git a/crates/window-core/src/pipeline/glyphs.rs b/crates/window-core/src/pipeline/glyphs.rs index d9091b5..36f2662 100644 --- a/crates/window-core/src/pipeline/glyphs.rs +++ b/crates/window-core/src/pipeline/glyphs.rs @@ -9,6 +9,7 @@ use crate::ir::{ArtIr, Draw, LaidOutHud, LaidOutWindow, SwitchIr}; use crate::size::{Pane, Part}; use crate::{Error, Result}; +use super::bars::clip_keys; use super::metrics::bitmap_metrics; use super::sprites::RuntimeSpriteAsset; use super::{CompileContext, OutputFile}; @@ -70,7 +71,7 @@ fn layers( } /// Allocate codepoints across the whole build: one key per static, case, or art layer composite -/// with content, plus one per runtime sprite when any window uses sprites. +/// with content, one per glyph of a bar's fill, plus one per runtime sprite when any window uses sprites. pub(super) fn allocate_codepoints( windows: &[&LaidOutWindow], huds: &[&LaidOutHud], @@ -102,8 +103,11 @@ fn layer_keys(keys: &mut BTreeSet, prefix: &str, switches: &[SwitchIr], } } for (art, comp) in art.iter().zip(&layers.art) { - if comp.has_content { - keys.insert(art_key(prefix, &art.name)); + let key = art_key(prefix, &art.name); + if art.bar.is_some() { + keys.extend(clip_keys(&key, &comp.texture)); + } else if comp.has_content { + keys.insert(key); } } } diff --git a/crates/window-core/src/pipeline/preview.rs b/crates/window-core/src/pipeline/preview.rs index 622ba6a..bfe3253 100644 --- a/crates/window-core/src/pipeline/preview.rs +++ b/crates/window-core/src/pipeline/preview.rs @@ -17,12 +17,13 @@ use crate::geometry::{Point, Rect, Size}; use crate::inventory::InventorySlotRef; use crate::ir::{ Align, AnvilInputIr, ArtIr, CollectionIr, Draw, Handle, HandleKind, HandleRole, HandleUse, ItemIr, LaidOutHud, - LaidOutWindow, Layer, RegionIr, Rgb, SlotIr, SpriteSlotIr, SwitchIr, + LaidOutWindow, Layer, RegionIr, Rgb, SlotIr, SpriteSlotIr, SwitchIr, bar_columns, }; use crate::model::{GeneratedStyle, SpriteDef}; use crate::surface::{ContainerKind, Surface}; use crate::{Error, Result, text_font}; +use super::bars::columns; use super::glyphs::{Layers, compose_layers}; use super::metrics::{GlyphMetrics, bitmap_metrics}; use super::sprites::texture_source_path; @@ -373,6 +374,8 @@ impl<'a> State<'a> { { None } + (HandleKind::Fraction, value) if fraction(value).is_some() => None, + (HandleKind::Fraction, _) => Some("a number from 0 to 1".to_string()), (HandleKind::Flag | HandleKind::Toggle | HandleKind::Items, _) => Some("true or false".to_string()), (HandleKind::Text | HandleKind::Input, _) => Some("a string".to_string()), (HandleKind::Value | HandleKind::Selection | HandleKind::Tint, _) => { @@ -443,6 +446,11 @@ impl<'a> State<'a> { Rgb::parse_hex(handle.colors.get(entry)?) } + /// The fraction bar `name` is filled to: the one set, or 1. + fn fraction(&self, name: &str) -> f64 { + self.get(HandleRole::Bar, name).and_then(fraction).unwrap_or(1.0) + } + /// The filled cell count and selected cell of `collection`. fn collection(&self, collection: &CollectionIr) -> Result<(usize, Option)> { let cells = collection.slots.len(); @@ -461,6 +469,12 @@ impl<'a> State<'a> { } } +/// The fraction `value` states: a number from 0 to 1. +fn fraction(value: &StateValue) -> Option { + let StateValue::Other(value) = value else { return None }; + value.as_f64().filter(|fraction| (0.0..=1.0).contains(fraction)) +} + /// What a name inside a switch case names. One handle can name both a switch and the region toggling it. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Member { @@ -635,8 +649,12 @@ impl Scene<'_> { } Layer::Art(name) if hidden.shows(Member::Art, name) => { if let Some(index) = pane.art.iter().position(|art| &art.name == name) { - let tint = pane.art[index].tint.as_deref().and_then(|tint| state.tint(tint)); - canvas.blit_composite(&layers.art[index], tint); + let (art, comp) = (&pane.art[index], &layers.art[index]); + let tint = art.tint.as_deref().and_then(|tint| state.tint(tint)); + match &art.bar { + Some(bar) => canvas.blit_composite(&filled(comp, state.fraction(bar)), tint), + None => canvas.blit_composite(comp, tint), + } } } _ => {} @@ -798,6 +816,13 @@ impl Placement<'_> { } } +/// The columns of bar fill `comp` drawn at `fraction`. +fn filled(comp: &Composite, fraction: f64) -> Composite { + let width = bar_columns(fraction, comp.texture.width); + let bounds = Rect::new(comp.bounds.x, comp.bounds.y, width, comp.bounds.height); + Composite { texture: columns(&comp.texture, 0, width), bounds, has_content: width > 0 } +} + fn find<'a, T>(items: &'a [T], name: &str, key: impl Fn(&T) -> &String) -> Result<&'a T> { items .iter() diff --git a/crates/window-core/src/pipeline/preview/tests.rs b/crates/window-core/src/pipeline/preview/tests.rs index 5315cf3..740d991 100644 --- a/crates/window-core/src/pipeline/preview/tests.rs +++ b/crates/window-core/src/pipeline/preview/tests.rs @@ -375,3 +375,21 @@ fn tinted_art_outside_the_surface_grows_the_canvas_within_the_pixel_limit() { let error = preview_white(json!([far]), json!([])).unwrap_err().to_string(); assert!(error.starts_with("preview `shot`: its ") && error.contains("exceeds the limit"), "{error}"); } + +#[test] +fn a_bar_draws_the_previewed_fraction_of_its_fill_and_defaults_to_full() { + let bar = json!({ "type": "bar", "handle": { "kind": "fraction", "id": "hp" }, "tint": tone(), "x": 10, "y": 10, + "width": 100, "height": 4, + "fill": { "art": "texture", "texture": "white.png" }, "track": { "art": "texture", "texture": "green.png" } }); + let shot = |state: Value| preview_white(json!([bar.clone()]), state); + + assert_eq!(columns(&shot(json!([])).unwrap(), RED), Some((10, 109))); + let partial = shot(json!([{ "handle": "hp", "value": 0.42 }, { "handle": "tone", "value": "cold" }])).unwrap(); + assert_eq!(columns(&partial, BLUE), Some((10, 51))); + assert_eq!(columns(&partial, GREEN), Some((52, 109)), "the track shows past the fill"); + assert_eq!(columns(&shot(json!([{ "handle": "hp", "value": 0 }])).unwrap(), RED), None); + assert_eq!( + shot(json!([{ "handle": "hp", "value": 1.5 }])).unwrap_err().to_string(), + "preview `shot`: handle `hp` is a fraction and takes a number from 0 to 1; got 1.5" + ); +} diff --git a/crates/window-core/src/pipeline/switches.rs b/crates/window-core/src/pipeline/switches.rs index bf89fc0..1dd2266 100644 --- a/crates/window-core/src/pipeline/switches.rs +++ b/crates/window-core/src/pipeline/switches.rs @@ -57,7 +57,8 @@ impl CompileContext<'_> { } /// Bakes every art layer of `art` net-zero from `origin`, where `comps` holds their composites in the same - /// order. `key_prefix` and `file_base` name the owning surface's glyph keys and textures. + /// order: one glyph each, or the clips of a bar's fill. `key_prefix` and `file_base` name the owning surface's + /// glyph keys and textures. pub(super) fn art_entries( &mut self, pane: Pane<'_>, @@ -72,9 +73,17 @@ impl CompileContext<'_> { let ascent = origin.y + 7 - comp.bounds.y; let file = format!("{file_base}_{}", art.name); let key = art_key(key_prefix, &art.name); - let glyph = self.emit_static_glyph(comp, ascent, pane, &file, key, Part::art_layer(pane))?; - let static_text = bake::bake_static_with_advance(glyph.glyph, &comp.bounds, origin, glyph.advance); - let entry = ArtEntry { static_text, tint: art.tint.clone(), tint_markers: BTreeMap::new() }; + let (static_text, bar) = match &art.bar { + Some(bar) => { + let bar = self.bar_entry(bar, comp, pane, &key, &file, origin)?; + (bar.compose(bar.width), Some(bar)) + } + None => { + let glyph = self.emit_static_glyph(comp, ascent, pane, &file, key, Part::art_layer(pane))?; + (bake::bake_static_with_advance(glyph.glyph, &comp.bounds, origin, glyph.advance), None) + } + }; + let entry = ArtEntry { static_text, bar, tint: art.tint.clone(), tint_markers: BTreeMap::new() }; entries.insert(art.name.clone(), entry); } Ok(entries) diff --git a/crates/window-core/src/pipeline/tests/huds.rs b/crates/window-core/src/pipeline/tests/huds.rs index b5d6a2e..ac8ba22 100644 --- a/crates/window-core/src/pipeline/tests/huds.rs +++ b/crates/window-core/src/pipeline/tests/huds.rs @@ -147,7 +147,7 @@ fn an_art_layer_bounds_the_row_offset_like_static_art() { insets: Insets::uniform(1), dest: Rect::new(0, 0, 80, 20), }; - tinted.art = vec![ArtIr { name: "art/0".into(), draws: vec![frame], tint: None }]; + tinted.art = vec![ArtIr { name: "art/0".into(), draws: vec![frame], tint: None, bar: None }]; tinted.layers = vec![Layer::Art("art/0".into())]; let out = compile_huds(&[hud("a", Some(SHADER), &[0, 10], 0), tinted], true).expect("the art layer fits a page"); diff --git a/crates/window-core/tests/bars.rs b/crates/window-core/tests/bars.rs new file mode 100644 index 0000000..e3c070d --- /dev/null +++ b/crates/window-core/tests/bars.rs @@ -0,0 +1,202 @@ +//! Bars: a fill cut into power-of-two clips that compose to any column count, with a track in the art underneath. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; +use window_core::codegen::{KotlinTarget, generate_kotlin}; +use window_core::compose::Texture; +use window_core::ir::Layer; +use window_core::manifest::BarEntry; +use window_core::pipeline::{CompileInput, CompileOutput, compile_project_json}; + +const WIDTH: u32 = 37; +const HEIGHT: u32 = 3; + +/// A fill whose every column differs, with a transparent column and a column whose only ink is its bottom row. +fn uneven_fill() -> Texture { + let mut fill = Texture::transparent(WIDTH, HEIGHT); + for x in (0..WIDTH).filter(|x| *x != 5) { + for y in (0..HEIGHT).filter(|y| x != 20 || *y == HEIGHT - 1) { + let i = ((y * WIDTH + x) * 4) as usize; + fill.rgba[i..i + 4].copy_from_slice(&[x as u8 * 6, y as u8 * 80, 0x40, 0xff]); + } + } + fill +} + +fn hp() -> Value { + json!({ "kind": "fraction", "id": "hp" }) +} + +fn flat(fill: &str) -> Value { + json!({ "art": "shape", "kind": "panel", "fill": fill, "border_width": 0, "radius": 0, "inset_depth": 0 }) +} + +fn bar(width: u32, fill: Value) -> Value { + json!({ "type": "bar", "handle": hp(), "fill": fill, "x": 8, "y": 20, "width": width, "height": HEIGHT }) +} + +fn compile(project: &Value, files: BTreeMap>) -> window_core::Result { + compile_project_json(project.to_string().as_bytes(), &CompileInput::new(files)) +} + +fn window(children: Value) -> Value { + json!({ "windows": [{ "name": "w", "container": "generic_9x3", "children": children }] }) +} + +fn error(children: Value) -> String { + compile(&window(children), BTreeMap::new()).expect_err("project should be rejected").to_string() +} + +fn texture(output: &CompileOutput, path: &str) -> Texture { + let file = output.files.iter().find(|file| file.path == path).unwrap_or_else(|| panic!("no file {path}")); + Texture::decode_png(file.contents.as_bytes()).unwrap() +} + +/// The texture of each glyph of the main font. +fn glyph_textures(output: &CompileOutput) -> BTreeMap { + let font = output.files.iter().find(|file| file.path == "assets/window/font/ui.json").unwrap(); + let font: Value = serde_json::from_slice(font.contents.as_bytes()).unwrap(); + let bitmaps = font["providers"].as_array().unwrap().iter().filter(|provider| provider["type"] == "bitmap"); + bitmaps + .map(|provider| { + let glyph = provider["chars"][0].as_str().unwrap().chars().next().unwrap(); + let file = provider["file"].as_str().unwrap().strip_prefix("window:").unwrap(); + (glyph, texture(output, &format!("assets/window/textures/{file}"))) + }) + .collect() +} + +/// The pixels `text` draws over a strip starting at the bar's left edge, and where its cursor ends. +fn draw(output: &CompileOutput, bar: &BarEntry, text: &str) -> (Texture, i32) { + let glyphs = glyph_textures(output); + let mut strip = Texture::transparent(bar.width, HEIGHT); + let mut cursor = 0; + for character in text.chars() { + if let Some(advance) = output.manifest.spacers.get(&(character as u32)) { + cursor += advance; + continue; + } + let glyph = &glyphs[&character]; + for x in 0..glyph.width { + for y in 0..HEIGHT { + let at = ((y as i32 * bar.width as i32 + cursor - bar.x + x as i32) * 4) as usize; + let from = ((y * glyph.width + x) * 4) as usize; + assert_eq!(strip.rgba[at + 3], 0, "a column is drawn twice"); + strip.rgba[at..at + 4].copy_from_slice(&glyph.rgba[from..from + 4]); + } + } + cursor += bar.advances[&character] as i32; + } + (strip, cursor) +} + +#[test] +fn every_column_count_draws_exactly_the_leftmost_columns_of_the_fill_and_returns_the_cursor() { + let fill = uneven_fill(); + let files = BTreeMap::from([("fill.png".to_string(), fill.encode_png().unwrap())]); + let project = window(json!([bar(WIDTH, json!({ "art": "texture", "texture": "fill.png" }))])); + let output = compile(&project, files).unwrap(); + let entry = &output.manifest.windows["w"]; + let art = &entry.art["art/0"]; + let bar = art.bar.as_ref().unwrap(); + + assert_eq!(entry.layers, [Layer::Art("art/0".into())]); + assert_eq!((bar.name.as_str(), bar.width, bar.levels.len()), ("hp", WIDTH, 6)); + assert_eq!(art.static_text, bar.compose(WIDTH)); + for columns in 0..=WIDTH { + let text = bar.compose(columns); + let (strip, cursor) = draw(&output, bar, &text); + assert_eq!(cursor, 0, "{columns} columns leave the cursor where they started"); + let mut expected = Texture::transparent(WIDTH, HEIGHT); + for y in 0..HEIGHT as usize { + let row = y * WIDTH as usize * 4; + let end = row + columns as usize * 4; + expected.rgba[row..end].copy_from_slice(&fill.rgba[row..end]); + } + assert_eq!(strip.rgba, expected.rgba, "{columns} columns"); + // One glyph per set bit of the column count, less the clips with nothing to draw. + let glyphs = text.chars().filter(|character| bar.advances.contains_key(character)).count(); + assert!(glyphs <= columns.count_ones() as usize, "{columns} columns took {glyphs} glyphs"); + } + assert_eq!(bar.compose(WIDTH + 9), bar.compose(WIDTH), "a count past the width draws the whole fill"); +} + +#[test] +fn a_fill_whose_columns_repeat_costs_one_glyph_per_clip_width() { + let mut element = bar(120, flat("#ffffff")); + element["track"] = flat("#202020"); + element["tint"] = json!("#ff0000"); + let output = compile(&window(json!([element])), BTreeMap::new()).unwrap(); + let entry = &output.manifest.windows["w"]; + let bar = entry.art["art/0"].bar.as_ref().unwrap(); + + assert_eq!(bar.advances.len(), 7); + assert_eq!(bar.levels.iter().map(|level| level.chars().count()).collect::>(), [60, 30, 15, 8, 4, 2, 1]); + let fills = output.files.iter().filter(|file| file.path.starts_with("assets/window/textures/font/w_art/")); + assert_eq!(fills.count(), 7); + // A fixed tint is baked into the fill, and the track is part of the static art underneath. + assert_eq!(texture(&output, "assets/window/textures/font/w_art/0_6_0.png").rgba[..4], [0xff, 0, 0, 0xff]); + assert_eq!(texture(&output, "assets/window/textures/font/w.png").rgba[..4], [0x20, 0x20, 0x20, 0xff]); + + let files = generate_kotlin(&output.manifest, "demo", KotlinTarget::Minestom).unwrap(); + let source = |path: &str| { + let file = files.iter().find(|file| file.path == path).unwrap(); + String::from_utf8(file.contents.as_bytes().to_vec()).unwrap() + }; + let view = source("WView.kt"); + for line in ["protected abstract fun hp(): Float", "bar(\"hp\") { hp() }"] { + assert!(view.contains(line), "missing `{line}` in:\n{view}"); + } + assert!(source("WindowEntries.kt").contains("bar = BarEntry(")); +} + +#[test] +fn a_tinted_bar_in_a_shader_placed_hud_gets_a_marker_per_palette_entry() { + let tone = json!({ "kind": "tint", "id": "tone", "values": ["ok", "low"], "colors": ["#3ddc84", "#ff4d4d"] }); + let mut element = bar(64, flat("#ffffff")); + element["tint"] = tone; + let shader = json!({ "source_bottom": 59, "origin": { "x": 0.5, "y": 0.0 }, "anchor": { "x": 0.5, "y": 0.0 } }); + let hud = json!({ "name": "status", "channel": "actionbar", "width": 120, "height": 30, "shader": shader, + "children": [element] }); + let project = json!({ + "options": { "core_shaders": { "hud": true } }, + "target": { "pack_format": 88 }, + "huds": [hud] + }); + let output = compile(&project, BTreeMap::new()).unwrap(); + let art = &output.manifest.huds["status"].art["art/0"]; + + assert_eq!(art.tint.as_deref(), Some("tone")); + assert_eq!(art.tint_markers.keys().collect::>(), ["low", "ok"]); + assert_eq!(art.bar.as_ref().unwrap().x, 8); +} + +#[test] +fn invalid_bars_are_rejected_with_the_cause() { + let sized = |width: u32, height: u32| { + let mut element = bar(width, flat("#ffffff")); + element["height"] = json!(height); + element + }; + let mut unfilled = bar(40, flat("#ffffff")); + unfilled.as_object_mut().unwrap().remove("fill"); + let mut on_text = bar(40, flat("#ffffff")); + on_text["handle"] = json!({ "kind": "text", "id": "hp" }); + let mut missing = bar(40, json!({ "art": "texture", "texture": "nowhere.png" })); + missing["debug_name"] = json!("health"); + let cases = [ + (sized(0, 4), "bar element is 0x4 px; its `width` and `height` must each be from 1 to 256"), + (sized(300, 4), "bar element is 300x4 px; its `width` and `height` must each be from 1 to 256"), + (sized(40, 0), "bar element is 40x0 px"), + (unfilled, "bar element requires `fill`"), + (on_text, "a text handle cannot be used as a bar's fill"), + (missing, "nowhere.png"), + ]; + for (element, expected) in cases { + let message = error(json!([element])); + assert!(message.contains(expected), "expected `{expected}` in: {message}"); + } + let slot = json!({ "type": "slot", "handle": hp(), "x": 8, "y": 20, "width": 40 }); + assert!(error(json!([slot])).contains("a fraction handle cannot be used as text")); +} From 513b5e38e40279736c0422db6b18df801d15e11d Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:51:12 -0400 Subject: [PATCH 2/6] feat(runtime): draw bars up to a bound fraction Windows and HUDs bind each bar to a lambda returning a fraction, compose the clips for its column count, and recompose only when that count changes. --- .../kotlin/com/chunkzero/window/HudScope.kt | 13 ++- .../kotlin/com/chunkzero/window/HudView.kt | 32 ++++++-- .../com/chunkzero/window/WindowScope.kt | 12 +++ .../com/chunkzero/window/WindowSession.kt | 2 + .../com/chunkzero/window/internal/Bars.kt | 79 +++++++++++++++++++ .../chunkzero/window/internal/HudComposer.kt | 8 +- .../chunkzero/window/internal/RenderKey.kt | 5 ++ .../window/internal/TitleComposer.kt | 11 ++- .../window/internal/WindowBindings.kt | 11 +++ .../window/internal/WindowRenderer.kt | 5 +- .../chunkzero/window/internal/WindowTitle.kt | 19 ++++- .../window/manifest/WindowEntries.kt | 32 +++++++- .../kotlin/com/chunkzero/window/BarsTest.kt | 69 ++++++++++++++++ .../chunkzero/window/WindowRendererTest.kt | 22 ++++++ 14 files changed, 298 insertions(+), 22 deletions(-) create mode 100644 jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Bars.kt create mode 100644 jvm/runtime/src/test/kotlin/com/chunkzero/window/BarsTest.kt diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudScope.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudScope.kt index 17418a3..9ab4678 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudScope.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudScope.kt @@ -5,8 +5,8 @@ import net.kyori.adventure.text.Component /** * The binding surface available inside [HudView.bind]. * - * Every dynamic slot, switch, and tint in the HUD manifest must be bound exactly once; static labels carrying `text` - * render automatically and must not be bound. + * Every dynamic slot, switch, tint, and bar in the HUD manifest must be bound exactly once; static labels carrying + * `text` render automatically and must not be bound. * * Bindings are fixed once `bind()` returns: calling a binder on a retained scope afterwards throws * [IllegalStateException]. @@ -41,4 +41,13 @@ public interface HudScope { name: String, render: () -> String, ) + + /** + * Binds a bar to a [render] lambda returning how much of its fill to draw, from 0 to 1. The fraction is + * clamped to that range and rounded to whole pixel columns of the bar's width. + */ + public fun bar( + name: String, + render: () -> Float, + ) } diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudView.kt index f240ed6..0b0de72 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/HudView.kt @@ -6,6 +6,7 @@ import com.chunkzero.window.diagnostics.RenderFrameReason import com.chunkzero.window.diagnostics.RenderLayerKind import com.chunkzero.window.diagnostics.RenderStyleTrace import com.chunkzero.window.diagnostics.RenderSurfaceKind +import com.chunkzero.window.internal.Bars import com.chunkzero.window.internal.ComposedRender import com.chunkzero.window.internal.DynamicSlots import com.chunkzero.window.internal.FrameCursor @@ -22,8 +23,8 @@ import net.kyori.adventure.text.Component import org.slf4j.LoggerFactory /** - * A user-defined view over a HUD: declares the providers of its slots, switches, and tints, and composes them on - * [render]. + * A user-defined view over a HUD: declares the providers of its slots, switches, tints, and bars, and composes them + * on [render]. * * A view only produces content. The caller decides when to render, sends the result on [channel], and resends it as * needed. A view is not thread-safe; render it from one thread at a time. @@ -34,6 +35,7 @@ public abstract class HudView( private val entry = definition.entry private val switches = Switches("hud", definition.name, entry.switches, null) private val tints = Tints("hud", definition.name, entry.tints, null) + private val bars = Bars("hud", definition.name, entry.art, null) private val slots = DynamicSlots( "hud", @@ -79,12 +81,12 @@ public abstract class HudView( get() = definition.channel /** - * Evaluates every slot, switch, and tint provider and returns the composed HUD. + * Evaluates every slot, switch, tint, and bar provider and returns the composed HUD. * - * The first call runs [bind]. Slots whose provider returns an equal component, and switches and tints that keep - * their case or palette entry, reuse their previous layout. + * The first call runs [bind]. Slots whose provider returns an equal component, switches and tints that keep + * their case or palette entry, and bars that keep their columns reuse their previous layout. * - * @throws IllegalStateException on the first call if a dynamic slot, switch, or tint is unbound. + * @throws IllegalStateException on the first call if a dynamic slot, switch, tint, or bar is unbound. * @throws IllegalArgumentException for unknown names referenced in [bind], or an unknown switch case or palette * entry. */ @@ -99,6 +101,7 @@ public abstract class HudView( slots.label(slot)?.let { segments[slot] = it } } } + for (name in bars.names) unpublished = bars.update(name) || unpublished for (name in slots.names) { val content = slots.content(name) if (contents[name] == content) continue @@ -109,7 +112,7 @@ public abstract class HudView( return if (unpublished) publish(compose(), RenderFrameReason.REACTIVE_UPDATE) else previous } - /** Registers the providers of this HUD's slots, switches, and tints; runs once, on the first [render]. */ + /** Registers the providers of this HUD's slots, switches, tints, and bars; runs once, on the first [render]. */ protected abstract fun HudScope.bind() /** @@ -162,16 +165,26 @@ public abstract class HudView( check(!sealed) { "Bindings are fixed once bind() returns; declare '$name' in bind()" } tints.bind(name, render) } + + override fun bar( + name: String, + render: () -> Float, + ) { + check(!sealed) { "Bindings are fixed once bind() returns; declare '$name' in bind()" } + bars.bind(name, render) + } } scope.bind() sealed = true slots.validate() switches.validate() tints.validate() + bars.validate() bound = true } for (name in switches.names) switches.update(name) for (name in tints.names) tints.update(name) + for (name in bars.names) bars.update(name) slots.seed(::seedSlot) { name, segment -> segments[name] = segment } return compose() } @@ -215,7 +228,10 @@ public abstract class HudView( LayerKind.ART -> { val art = entry.art.getValue(name).takeIf { switches.artActive(name) } - art?.let { definition.composer.art("hud/${definition.name}/$name", it, tints.value(it.tint)) } + art?.let { + val id = "hud/${definition.name}/$name" + definition.composer.art(id, it, tints.value(it.tint), bars.columns(it.bar)) + } } LayerKind.SPRITE_SLOT, LayerKind.COLLECTION -> { diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowScope.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowScope.kt index 914b512..d4f9458 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowScope.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowScope.kt @@ -127,6 +127,18 @@ public interface WindowScope { render: () -> String, ) + /** + * Binds a bar to a [render] lambda returning how much of its fill to draw, from 0 to 1; the lambda is + * re-invoked whenever a reactive state it reads changes. The fraction is clamped to that range and + * rounded to whole pixel columns of the bar's width. + * + * @throws IllegalArgumentException if [name] is not a bar of the window. + */ + public fun bar( + name: String, + render: () -> Float, + ) + /** * Binds a native anvil rename field. The handler receives each value the player types. With * experimental anvil updates, the text the client sends back after a reopen is not passed on. diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowSession.kt index 3f933f0..ed0d162 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowSession.kt @@ -22,6 +22,7 @@ import com.chunkzero.window.internal.WindowFrame import com.chunkzero.window.internal.WindowRenderer import com.chunkzero.window.internal.hasStaticTitle import com.chunkzero.window.internal.requireEntry +import com.chunkzero.window.internal.titleBars import com.chunkzero.window.internal.titleSlots import com.chunkzero.window.internal.titleTints import net.kyori.adventure.text.Component @@ -73,6 +74,7 @@ internal open class ContainerWindowSession( definition.titleSlots(reactivity, tints), Switches("window", definition.name, entry.switches, reactivity), tints, + definition.titleBars(reactivity), ) protected val renderer = WindowRenderer(definition, bindings, reactivity, host::item) diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Bars.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Bars.kt new file mode 100644 index 0000000..51cee2c --- /dev/null +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/Bars.kt @@ -0,0 +1,79 @@ +package com.chunkzero.window.internal + +import com.chunkzero.window.manifest.ArtEntry +import com.chunkzero.window.manifest.BarEntry +import kotlin.math.roundToInt + +/** + * The bars of one window or HUD: binds each bar to a lambda returning how much of it is filled, and tracks + * the columns each last drew. + */ +internal class Bars( + /** The surface name used in messages, `window` or `hud`. */ + private val surface: String, + private val ownerName: String, + art: Map, + /** Captures state reads per binding; `null` for surfaces that render without reactivity. */ + private val reactivity: Reactivity?, +) { + private val bars: Map = art.values.mapNotNull { it.bar }.associateBy { it.name } + + /** Bar names. */ + val names: Set = bars.keys + + private val renders = HashMap Float>() + private val columns = HashMap() + + fun bind( + name: String, + render: () -> Float, + ) { + require(name in bars) { "Unknown bar '$name' in $surface '$ownerName'; known bars: ${names.sorted()}" } + require(renders.put(name, render) == null) { "Bar '$name' bound more than once" } + } + + fun validate() { + val unbound = names - renders.keys + check(unbound.isEmpty()) { "Unbound bars in $surface '$ownerName': ${unbound.sorted()}" } + } + + /** + * Evaluates bar [name]'s binding under dependency capture and takes the columns its fraction fills; + * returns whether they changed. + */ + fun update(name: String): Boolean { + val render = renders.getValue(name) + val fraction = if (reactivity == null) render() else reactivity.withRendering(RenderKey.Bar(name), render) + val filled = bars.getValue(name).columns(fraction) + return columns.put(name, filled) != filled + } + + /** The columns [bar] last drew; `null` without a bar. */ + fun columns(bar: BarEntry?): Int? = bar?.let { columns.getValue(it.name) } +} + +/** The columns this bar draws at [fraction]: the nearest whole column, with the fraction clamped to 0 through 1. */ +internal fun BarEntry.columns(fraction: Float): Int = + if (fraction.isNaN()) 0 else (fraction.coerceIn(0f, 1f) * width).roundToInt() + +/** The net-zero text that draws the leftmost [columns] columns of this bar's fill. */ +internal fun BarEntry.compose( + columns: Int, + spacers: Spacers, +): String { + val text = StringBuilder() + var cursor = 0 + var start = 0 + for (level in levels.indices.reversed()) { + val size = 1 shl level + if (columns and size == 0) continue + val glyph = levels[level][start shr (level + 1)] + if (glyph != ' ') { + val at = x + start + text.append(spacers.compose(at - cursor)).append(glyph) + cursor = at + advances.getValue(glyph.toString()) + } + start += size + } + return text.append(spacers.compose(-cursor)).toString() +} diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HudComposer.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HudComposer.kt index 217ac1d..ab66464 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HudComposer.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/HudComposer.kt @@ -55,15 +55,19 @@ internal class HudComposer( source: String?, ): HudLayer.Art = HudLayer.Art(semanticId, static, source, staticColor) - /** Art layer [art], multiplied by the color of its tint's palette entry [tint]. */ + /** + * Art layer [art], multiplied by the color of its tint's palette entry [tint]. A bar's fill is drawn up to + * [columns]. + */ fun art( semanticId: String, art: ArtEntry, tint: String?, + columns: Int? = null, ): HudLayer.Art = HudLayer.Art( semanticId, - art.static, + art.bar?.let { it.compose(columns ?: it.width, spacers) } ?: art.static, null, segmentColor(art.tint, tint, art.tintMarkers) ?: staticColor, ) diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/RenderKey.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/RenderKey.kt index 4a7cf24..a685201 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/RenderKey.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/RenderKey.kt @@ -37,4 +37,9 @@ internal sealed interface RenderKey { data class Tint( val name: String, ) : RenderKey + + /** A bar binding, returning how much of it is filled. */ + data class Bar( + val name: String, + ) : RenderKey } diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/TitleComposer.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/TitleComposer.kt index bab1ac3..b22bb42 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/TitleComposer.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/TitleComposer.kt @@ -129,16 +129,21 @@ internal class TitleComposer( static: String, ): RenderedSegment = RenderedSegment(Component.text(static).style(spacerStyle), staticTrace(semanticId, static)) - /** Renders art layer [art], multiplied by the color of its tint's palette entry [tint]. */ + /** + * Renders art layer [art], multiplied by the color of its tint's palette entry [tint]. A bar's fill is drawn up + * to [columns]. + */ fun renderArt( semanticId: String, art: ArtEntry, tint: String?, + columns: Int? = null, ): RenderedSegment { val color = window.tints.tintColor(art.tint, tint) ?: NamedTextColor.WHITE + val static = art.bar?.let { it.compose(columns ?: it.width, spacers) } ?: art.static return RenderedSegment( - Component.text(art.static).style(baseStyle(font, color)), - staticTrace(semanticId, art.static, color), + Component.text(static).style(baseStyle(font, color)), + staticTrace(semanticId, static, color), ) } diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowBindings.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowBindings.kt index cf73891..664e792 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowBindings.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowBindings.kt @@ -16,6 +16,7 @@ internal class WindowBindings( val slots: DynamicSlots, val switches: Switches, val tints: Tints, + val bars: Bars, ) : WindowScope { private val entry = definition.entry @@ -139,6 +140,14 @@ internal class WindowBindings( tints.bind(name, render) } + override fun bar( + name: String, + render: () -> Float, + ) { + requireBindable(name) + bars.bind(name, render) + } + override fun anvilInput( name: String, handler: (String) -> Unit, @@ -167,6 +176,7 @@ internal class WindowBindings( slots.validate() switches.validate() tints.validate() + bars.validate() val unboundSprites = entry.spriteSlots.filter { (key, slot) -> slot.sprite == null && key !in sprites }.keys checkNone(unboundSprites) { "Unbound dynamic sprite slots in window '$it'" } val defaults = entry.regions.values.mapNotNull { it.defaultAction } @@ -195,6 +205,7 @@ internal class WindowBindings( collectionSelections.keys.mapTo(this, RenderKey::CollectionSelection) switches.names.mapTo(this, RenderKey::Switch) tints.names.mapTo(this, RenderKey::Tint) + bars.names.mapTo(this, RenderKey::Bar) for ((name, collection) in entry.collections) { if (name !in collectionItems) continue collection.slots.indices.mapTo(this) { RenderKey.CollectionCell(name, it) } diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowRenderer.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowRenderer.kt index f83ba2f..4df84ff 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowRenderer.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowRenderer.kt @@ -29,13 +29,15 @@ internal class WindowRenderer( ) { private val switches = bindings.switches private val tints = bindings.tints + private val bars = bindings.bars private val title = WindowTitle(definition, bindings, reactivity) private val inventory = WindowInventory(definition, bindings, reactivity, buildItem) - /** Selects every switch's case, picks every tint's palette entry, and renders the initial title. */ + /** Selects every switch's case, picks every tint's palette entry, fills every bar, and renders the initial title. */ fun seedTitle(): ComposedRender { for (name in switches.names) switches.update(name) for (name in tints.names) tints.update(name) + for (name in bars.names) bars.update(name) title.seed() return title.compose() } @@ -57,6 +59,7 @@ internal class WindowRenderer( is RenderKey.CollectionSelection -> title.updateCollectionSelection(key.name) is RenderKey.Switch -> if (switches.update(key.name)) updateCases() is RenderKey.Tint -> if (tints.update(key.name)) title.updateTint(key.name) + is RenderKey.Bar -> if (bars.update(key.name)) title.updateBars() } } return frame() diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowTitle.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowTitle.kt index 4829bce..5ac576a 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowTitle.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/internal/WindowTitle.kt @@ -22,12 +22,13 @@ internal class WindowTitle( private val composer = definition.composer private val switches = bindings.switches private val tints = bindings.tints + private val bars = bindings.bars private val segments = HashMap() private var composed: ComposedRender? = null /** - * Composes the current segments; repeated calls return the same render until a segment is written or - * [updateCases] reports a case change. + * Composes the current segments; repeated calls return the same render until a segment is written, or + * [updateCases] or [updateBars] reports a change. */ fun compose(): ComposedRender = composed ?: composer.compose(definition.name, entry.layers.mapNotNull(::layer)).also { composed = it } @@ -37,6 +38,11 @@ internal class WindowTitle( composed = null } + /** Marks the title for recomposition after the columns a bar draws changed. */ + fun updateBars() { + composed = null + } + /** Renders collection selections, sprite slots, and text slots once. */ fun seed() { for (name in bindings.collectionSelections.keys) updateCollectionSelection(name) @@ -110,7 +116,8 @@ internal class WindowTitle( LayerKind.ART -> { if (!switches.artActive(name)) return null val art = entry.art.getValue(name) - return composer.renderArt("window/${definition.name}/$name", art, tints.value(art.tint)) + val id = "window/${definition.name}/$name" + return composer.renderArt(id, art, tints.value(art.tint), bars.columns(art.bar)) } } val source = @@ -171,8 +178,11 @@ internal fun WindowDefinition.titleSlots( /** This window's tints. */ internal fun WindowDefinition.titleTints(reactivity: Reactivity): Tints = Tints("window", name, entry.tints, reactivity) +/** This window's bars. */ +internal fun WindowDefinition.titleBars(reactivity: Reactivity): Bars = Bars("window", name, entry.art, reactivity) + /** - * Whether the title never changes after open: no text slots, bound sprites, selections, or tints, and no + * Whether the title never changes after open: no text slots, bound sprites, selections, tints, or bars, and no * switch case with art, slots, or sprite slots. Switches that only swap regions keep the title. */ internal val WindowEntry.hasStaticTitle: Boolean @@ -181,6 +191,7 @@ internal val WindowEntry.hasStaticTitle: Boolean spriteSlots.values.all { it.sprite != null } && collections.values.all { it.selection.isEmpty() } && tints.isEmpty() && + art.values.all { it.bar == null } && switches.values.all { switch -> switch.cases.all { it.static.isEmpty() && it.slots.isEmpty() && it.spriteSlots.isEmpty() && it.art.isEmpty() diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/WindowEntries.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/WindowEntries.kt index ec2bf4e..b1efd9c 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/WindowEntries.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/WindowEntries.kt @@ -40,17 +40,45 @@ public data class WindowEntry( val layers: List = emptyList(), ) -/** Art drawn as its own glyph: tinted art, or the untinted art authored over it. */ +/** Art drawn as its own glyphs: tinted art, the untinted art authored over it, or the fill of a bar. */ @Serializable public data class ArtEntry( - /** Net-zero baked art in [WindowManifest.font], starting and ending where a switch case's does. */ + /** + * Net-zero baked art in [WindowManifest.font], starting and ending where a switch case's does. A bar's is its + * whole fill. + */ @SerialName("static") val static: String, + /** Set when the art is the fill of a bar, drawn up to the fraction its binding returns instead of [static]. */ + val bar: BarEntry? = null, /** The tint whose current palette color the art is sent in; `null` art is sent white. */ val tint: String? = null, /** In a shader-placed HUD, the marker color to send per palette entry instead of the entry's color. */ @SerialName("tint_markers") val tintMarkers: Map = emptyMap(), ) +/** + * The fill of a bar: art of which the leftmost columns are drawn, up to a fraction of [width]. + * + * The fill is cut into clips of 1, 2, 4, … columns, a clip of `2^k` columns starting at each multiple of + * `2^(k + 1)`. Any column count is one clip per set bit of the count, laid left to right from the largest. + */ +@Serializable +public data class BarEntry( + /** The name the fraction is bound to. */ + val name: String, + /** The fill's left edge, in pixels right of the cursor origin that art segments start and end at. */ + val x: Int, + /** The fill's width in columns: what a fraction of 1 draws. */ + val width: Int, + /** + * `levels[k]` holds one glyph of [WindowManifest.font] per clip of `2^k` columns: its character `i` draws the + * columns from `i shl (k + 1)`, and a space stands for a clip with nothing to draw. + */ + val levels: List, + /** The cursor advance of each glyph in [levels]. */ + val advances: Map, +) + /** * Cases of which at most one is active: the one whose value the switch's binding returns. A switch * listed by a case's [SwitchCaseEntry.switches] is active only while that case is. diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/BarsTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/BarsTest.kt new file mode 100644 index 0000000..f74f474 --- /dev/null +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/BarsTest.kt @@ -0,0 +1,69 @@ +package com.chunkzero.window + +import com.chunkzero.window.internal.Spacers +import com.chunkzero.window.internal.columns +import com.chunkzero.window.internal.compose +import com.chunkzero.window.manifest.BarEntry +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +private const val WIDTH = 11 +private const val X = 5 + +/** The columns each clip glyph draws, from the bar's left edge. */ +private val clips = HashMap() + +/** A bar of [WIDTH] columns at [X] with a distinct glyph per clip, each advancing one pixel past its columns. */ +private val bar: BarEntry = + run { + var glyph = 'a' + val levels = + (0..3).map { level -> + val size = 1 shl level + generateSequence(0) { it + 2 * size } + .takeWhile { it + size <= WIDTH } + .map { start -> glyph++.also { clips[it.code] = start until start + size } } + .joinToString("") + } + BarEntry( + name = "hp", + x = X, + width = WIDTH, + levels = levels, + advances = clips.entries.associate { (glyph, columns) -> glyph.toChar().toString() to columns.count() + 1 }, + ) + } + +class BarsTest : + StringSpec({ + "every column count draws exactly its leftmost columns and returns the cursor" { + val table = TestManifests.spacerTable() + for (columns in 0..WIDTH) { + val drawn = sortedSetOf() + var cursor = 0 + for (codepoint in bar.compose(columns, Spacers(table)).codePoints().toArray()) { + val clip = clips[codepoint] + if (clip == null) { + cursor += table.getValue(codepoint) + } else { + (cursor - X).let { it shouldBe clip.first } + drawn += clip + cursor += clip.count() + 1 + } + } + drawn.toList() shouldBe (0 until columns).toList() + cursor shouldBe 0 + } + } + + "a fraction fills the nearest whole column and is clamped to the bar" { + bar.columns(0f) shouldBe 0 + bar.columns(0.04f) shouldBe 0 + bar.columns(0.05f) shouldBe 1 + bar.columns(0.5f) shouldBe 6 + bar.columns(1f) shouldBe WIDTH + bar.columns(7f) shouldBe WIDTH + bar.columns(-1f) shouldBe 0 + bar.columns(Float.NaN) shouldBe 0 + } + }) diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/WindowRendererTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/WindowRendererTest.kt index d08a78f..fdb4a27 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/WindowRendererTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/WindowRendererTest.kt @@ -9,10 +9,12 @@ import com.chunkzero.window.internal.Switches import com.chunkzero.window.internal.WindowBindings import com.chunkzero.window.internal.WindowFrame import com.chunkzero.window.internal.WindowRenderer +import com.chunkzero.window.internal.titleBars import com.chunkzero.window.internal.titleSlots import com.chunkzero.window.internal.titleTints import com.chunkzero.window.manifest.Align import com.chunkzero.window.manifest.ArtEntry +import com.chunkzero.window.manifest.BarEntry import com.chunkzero.window.manifest.HitboxEntry import com.chunkzero.window.manifest.ItemEntry import com.chunkzero.window.manifest.LayerEntry @@ -48,6 +50,7 @@ private class RendererFixture( definition.titleSlots(reactivity, tints), Switches("window", "w", definition.entry.switches, reactivity), tints, + definition.titleBars(reactivity), ) val renderer = WindowRenderer(definition, bindings, reactivity) { it } @@ -134,6 +137,25 @@ class WindowRendererTest : colorOf(changed, "PLAIN") shouldBe "#ffffff" } + "a bar draws the columns of its fraction, and redraws when they change" { + val fill = BarEntry("hp", 0, 4, listOf("ab", "c", "d"), mapOf("a" to 2, "b" to 2, "c" to 3, "d" to 5)) + val fixture = RendererFixture(TestManifests.manifest(art = mapOf("art/0" to ArtEntry("d", bar = fill)))) + var hp by fixture.reactivity.state(0.5f) + + plain(fixture.open { bar("hp") { hp } }) shouldContain "c" + + hp = 0.51f + fixture.scheduler.runAll() + fixture.frames.single().title shouldBeSameInstanceAs fixture.renderer.title() + + hp = 0.75f + fixture.scheduler.runAll() + val drawn = plain(fixture.frames.last().title) + drawn shouldContain "c" + drawn shouldContain "b" + drawn shouldNotContain "d" + } + "a nested switch draws only under its active outer case" { val label = { text: String -> TestManifests.slot(8, 40, Align.LEFT, text = text) } From dec21e8ca531ac597f165da5917c1f4872dc8258 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:51:12 -0400 Subject: [PATCH 3/6] feat(plugin): add the Bar primitive and fraction handle Closes #95. --- plugin/src/bind/handles.ts | 12 ++++- plugin/src/bind/index.ts | 2 + plugin/src/raw/index.ts | 1 + plugin/src/ui/components.ts | 41 ++++++++++++++- plugin/src/ui/document.ts | 20 ++++++++ plugin/src/ui/elements.ts | 17 ++++++- plugin/src/ui/index.ts | 2 + plugin/src/ui/previews.ts | 30 +++++++---- plugin/test/golden/bar/case.json | 6 +++ plugin/test/golden/bar/expected.sha256 | 62 +++++++++++++++++++++++ plugin/test/golden/bar/ts/window/bars.tsx | 32 ++++++++++++ plugin/test/jsx.test.ts | 19 ++++++- plugin/test/jsx.typecheck.tsx | 37 +++++++++++++- 13 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 plugin/test/golden/bar/case.json create mode 100644 plugin/test/golden/bar/expected.sha256 create mode 100644 plugin/test/golden/bar/ts/window/bars.tsx diff --git a/plugin/src/bind/handles.ts b/plugin/src/bind/handles.ts index 991e82d..2f97f89 100644 --- a/plugin/src/bind/handles.ts +++ b/plugin/src/bind/handles.ts @@ -11,7 +11,8 @@ export type HandleKind = | "action" | "input" | "builtin" - | "tint"; + | "tint" + | "fraction"; /** The extent of an indexed handle: `[n]` or `[rows, columns]`. */ export type Shape = readonly [number] | readonly [number, number]; @@ -65,6 +66,7 @@ export type Collection = Ref<"collection">; export type Action = Ref<"action">; export type Input = Ref<"input">; export type Builtin = Ref<"builtin">; +export type Fraction = Ref<"fraction">; export interface Value extends Ref<"value", V> { readonly values: readonly V[]; @@ -242,6 +244,14 @@ export function tint(id: string, palette: Readonly, none); } +/** How much of a `` is filled, from 0 to 1: `protected abstract fun health(): Float`. */ +export function fraction(id: string): Fraction; +export function fraction(id: string, options: { shape: S }): Indexed; +export function fraction(id: string, options?: { shape?: Shape }): unknown { + requireId(id, "fraction"); + return shaped({ kind: "fraction", id } as Fraction, options?.shape, none); +} + /** Dynamic text: `protected abstract fun title(): Component`. */ export function text(id: string): Text; export function text(id: string, options: { shape: S }): Indexed; diff --git a/plugin/src/bind/index.ts b/plugin/src/bind/index.ts index 4f06ab8..1f8be3f 100644 --- a/plugin/src/bind/index.ts +++ b/plugin/src/bind/index.ts @@ -4,6 +4,7 @@ export { builtin, collection, flag, + fraction, input, items, selection, @@ -21,6 +22,7 @@ export type { Collection as CollectionHandle, Condition, Flag as FlagHandle, + Fraction as FractionHandle, HandleKind, Indexed, Input as InputHandle, diff --git a/plugin/src/raw/index.ts b/plugin/src/raw/index.ts index 85b1221..5fa1ab3 100644 --- a/plugin/src/raw/index.ts +++ b/plugin/src/raw/index.ts @@ -3,6 +3,7 @@ import { text as textElement } from "../ui/elements.ts"; import * as textHelpers from "../ui/text.ts"; export { + bar, box, collection, hud, diff --git a/plugin/src/ui/components.ts b/plugin/src/ui/components.ts index 7794ce2..5148252 100644 --- a/plugin/src/ui/components.ts +++ b/plugin/src/ui/components.ts @@ -17,7 +17,7 @@ import type { } from "./styles.ts"; import type { Color, Token, VarTheme } from "./tokens.ts"; import type { ClickAction, Collection, Condition, Flag, Ref } from "../bind/handles.ts"; -import type { Input as InputHandle, Items as ItemsHandle } from "../bind/handles.ts"; +import type { Fraction as FractionHandle, Input as InputHandle, Items as ItemsHandle } from "../bind/handles.ts"; import type { Sprite as SpriteHandle, Text as TextHandle, Tint, Toggle as ToggleHandle } from "../bind/handles.ts"; import type { CaseElement, Element, Hud as HudDef, Window as WindowDef } from "./document.ts"; import type { @@ -114,6 +114,21 @@ export interface ImageBindProps extends ImagePlacement { export type ImageProps = ImageArtProps | ImageBindProps; +/** A bar whose `fill` is drawn from the left, up to the fraction its handle returns. */ +export interface BarProps extends ImagePlacement { + /** The `fraction` handle returning how much of the bar is filled, from 0 to 1. */ + bind: FractionHandle; + /** The bar's size in pixels, each from 1 to 256. */ + width: number; + height: number; + /** Art stretched over the bar; a fraction draws its leftmost columns. */ + fill: ArtRef; + /** Art stretched over the bar and drawn whole, under the fill. */ + track?: ArtRef; + /** Multiplies the fill's colors: a `tint` handle picks the color at runtime, a hex color is baked in. */ + tint?: Token | Tint; +} + /** * An inventory region: the slots it covers route clicks to `onClick` and show its hitbox item. Without `width` and * `height` it fills its parent box, or in a section its grid area. @@ -406,6 +421,30 @@ export function Image(input: ImageProps): Element { return clean({ type: "sprite", art: props.art, tint: props.tint, ...placement }) as Element; } +/** + * A bar of `width` by `height` pixels. `fill` is stretched over it and drawn from the left, up to the fraction the + * `fraction` handle `bind` returns, rounded to whole pixel columns; `track` is drawn whole under it. + */ +export function Bar(input: BarProps): Element { + const props = withStyle(input, STYLE_KEYS.item!, "Bar"); + if (typeof props.fill !== "object" || props.fill === null) { + throw new Error(" requires `fill`, such as texture(...) or shape(...)"); + } + return clean({ + type: "bar", + ...bound(props.bind, ["fraction"], ""), + width: props.width, + height: props.height, + fill: props.fill, + track: props.track, + tint: props.tint, + x: props.x, + y: props.y, + ...layout(props), + ...debug(props), + }) as Element; +} + /** * An inventory region: the slots it covers take its clicks and show its hitbox item. It fills its parent box, or in * a section claims its `span`/`at` area; inside switch cases, regions claim their slots only while their case is drawn. diff --git a/plugin/src/ui/document.ts b/plugin/src/ui/document.ts index 03e36bd..70b67a8 100644 --- a/plugin/src/ui/document.ts +++ b/plugin/src/ui/document.ts @@ -84,6 +84,21 @@ export interface SpriteSlotOptions extends FlexItemOptions, DebugName { style?: StyleValue; } +export interface BarOptions extends FlexItemOptions, DebugName { + /** The bar's size in pixels, each from 1 to 256. */ + width: number; + height: number; + /** Art stretched over the bar, of which the leftmost columns are drawn, up to the bound fraction. */ + fill: ArtRef; + /** Art stretched over the bar and drawn whole, under the fill. */ + track?: ArtRef; + /** Multiplies the fill's colors: a `tint` handle picks the color at runtime, a hex color is baked in. */ + tint?: Token | Tint; + x?: number; + y?: number; + style?: StyleValue; +} + export interface ItemOptions extends FlexItemOptions, DebugName { /** Backing inventory slots populated by this item. Integers mean container slots. */ slots?: SlotList; @@ -150,6 +165,10 @@ export interface SpriteSlotElement extends SpriteSlotOptions { type: "sprite_slot"; handle: HandleJson; } +export interface BarElement extends BarOptions { + type: "bar"; + handle: HandleJson; +} export interface ItemElement extends ItemOptions { type: "item"; handle: HandleJson; @@ -316,6 +335,7 @@ export interface SwitchElement extends SwitchOptions { export type Element = ( | SpriteElement | SpriteSlotElement + | BarElement | ItemElement | CollectionElement | AnvilInputElement diff --git a/plugin/src/ui/elements.ts b/plugin/src/ui/elements.ts index 0665910..8d9ab3e 100644 --- a/plugin/src/ui/elements.ts +++ b/plugin/src/ui/elements.ts @@ -1,10 +1,13 @@ import { isRef, refJson } from "../bind/handles.ts"; import { applyStyle } from "./styles.ts"; import { resolveTokens } from "./tokens.ts"; -import type { ClickAction, Collection, Condition, Input, Items, Ref, Sprite, Text } from "../bind/handles.ts"; +import type { ClickAction, Collection, Condition, Fraction, Input, Items, Ref } from "../bind/handles.ts"; +import type { Sprite, Text } from "../bind/handles.ts"; import type { AnvilInputElement, AnvilInputOptions, + BarElement, + BarOptions, BoxOptions, CaseElement, CaseOptions, @@ -111,6 +114,7 @@ const STYLE_KINDS: Readonly> = { slot: "text", sprite: "item", sprite_slot: "item", + bar: "item", region: "item", item: "item", collection: "collection", @@ -199,6 +203,17 @@ export function image( return { ...out, art: requireObject(source, "image art") } as unknown as SpriteElement; } +/** A bar that draws the leftmost columns of its `fill`, up to the fraction a `fraction` handle returns. */ +export function bar(bind: Fraction, opts: BarOptions): BarElement { + const out = element( + "bar", + opts, + ["width", "height", "fill", "track", "tint", "x", "y", "layout", "style", "debug_name"], + ["width", "height", "fill"], + ); + return { ...out, ...bound(bind, ["fraction"], "bar source") } as unknown as BarElement; +} + /** An inventory region: the slots it covers route clicks to `onClick` and show its hitbox item. */ export function region(opts: RegionOptions = {}): RegionElement { const out = element("region", opts, [ diff --git a/plugin/src/ui/index.ts b/plugin/src/ui/index.ts index 01e34ef..09535df 100644 --- a/plugin/src/ui/index.ts +++ b/plugin/src/ui/index.ts @@ -76,6 +76,7 @@ export type { WindowsDefinition } from "./windows.ts"; export { preview } from "./previews.ts"; export type { CollectionState, Preview, PreviewOptions, StateEntry, StateOf } from "./previews.ts"; export { + Bar, Box, Case, Collection, @@ -90,6 +91,7 @@ export { Window, } from "./components.ts"; export type { + BarProps, BoxProps, CaseProps, Child, diff --git a/plugin/src/ui/previews.ts b/plugin/src/ui/previews.ts index 1bd27a5..510efd7 100644 --- a/plugin/src/ui/previews.ts +++ b/plugin/src/ui/previews.ts @@ -11,7 +11,17 @@ export interface CollectionState { /** A handle with a previewed value: every kind but actions. */ type Stateful = Ref< - "flag" | "toggle" | "value" | "selection" | "tint" | "text" | "sprite" | "items" | "collection" | "input" + | "flag" + | "toggle" + | "value" + | "selection" + | "tint" + | "fraction" + | "text" + | "sprite" + | "items" + | "collection" + | "input" >; /** The value a preview gives the handle `H`. An indexed handle takes its members' values, nested as its shape. */ @@ -23,13 +33,15 @@ export type StateOf = H extends { at(...index: infer I): infer M } ? string : H extends Ref<"flag" | "toggle" | "items"> ? boolean - : H extends Ref<"value" | "selection" | "tint", infer V> - ? V - : H extends Sprite - ? O - : H extends Ref<"collection"> - ? CollectionState - : never; + : H extends Ref<"fraction"> + ? number + : H extends Ref<"value" | "selection" | "tint", infer V> + ? V + : H extends Sprite + ? O + : H extends Ref<"collection"> + ? CollectionState + : never; /** One handle and its previewed value, such as `[category, "gear"]` or `[label.at(1), "Gear"]`. */ export type StateEntry = readonly [handle: Stateful | { at(...index: never[]): Stateful }, value: unknown]; @@ -99,7 +111,7 @@ function members(id: string, shape: readonly number[], values: unknown, at: read * A PNG of `target`, a window or HUD, drawn with the handle values of `state`. List it in `defineWindows({ previews })`; * a build writes `.png` into the plugin's `previews.output` directory. A handle without a value takes its * default: a toggle's or selection's `initial`, the first value of a `value`, the first palette entry of a `tint`, - * `false` for a flag, no text, and no sprite. Item slots draw as outlined placeholders. + * `false` for a flag, 1 for a fraction, no text, and no sprite. Item slots draw as outlined placeholders. */ export function preview( target_: unknown, diff --git a/plugin/test/golden/bar/case.json b/plugin/test/golden/bar/case.json new file mode 100644 index 0000000..45d4731 --- /dev/null +++ b/plugin/test/golden/bar/case.json @@ -0,0 +1,6 @@ +{ + "packFormat": 88, + "window": { "coreShaders": { "hud": true } }, + "kotlinPackage": "golden.bar", + "kotlinTarget": "minestom" +} diff --git a/plugin/test/golden/bar/expected.sha256 b/plugin/test/golden/bar/expected.sha256 new file mode 100644 index 0000000..acd6930 --- /dev/null +++ b/plugin/test/golden/bar/expected.sha256 @@ -0,0 +1,62 @@ +950440cf29257b3e036e9aeb2aa7e8e8383e0855820f46b8cda64f77bdb029b6 dist/assets/minecraft/shaders/core/text.fsh +72fc12e724f22681f4cd5d7f306b1bfe9dfa9d23131110af91326a32473b8e76 dist/assets/minecraft/shaders/core/text.vsh +54e5c3c7f378530f6f91088c9e328e20f52de08e9354ab7cc3eee105010e189e dist/assets/minecraft/shaders/core/text_background.vsh +0cbb30ef896114df27d0d704ae45afe979a03e653e926997d4a8814d0a44bb0f dist/assets/window/font/ui.json +7808cab18784f3313c48cacaac418c3ea6aeee1a3afbf1ebf383f0594528aa54 dist/assets/window/font/y0.json +93a0921adf078edd360459b3fb927865d9c73191541118f4b829f328eaccb9df dist/assets/window/items/gui/hitbox.json +dc49481287414f39e74f7ef95a92814997f89f1b09b453bc34416f30787dd5b4 dist/assets/window/models/gui/hitbox.json +4a3ee791a3b3452c60c58213b8b2242d5b343f4deb22dbe78315780628432600 dist/assets/window/textures/font/furnace.png +dd5b8d024c3022afdefc9b73c9a1721724d2950d0f3b061e66665a638fdf0032 dist/assets/window/textures/font/furnace_art/0_0_0.png +46c9755f7f856e846253be7fb239e01cfbaadd1a55480f72c0d7ae432a787d6d dist/assets/window/textures/font/furnace_art/0_1_0.png +a90d6875d22070627f7557b08a741646f6ccbbf2d315746d80c6bf84e9ee18de dist/assets/window/textures/font/furnace_art/0_2_0.png +259f80505a61b1760c050ed00082e6ddd3baeba7c9e79479b096c1d43f85b8c4 dist/assets/window/textures/font/furnace_art/0_3_0.png +d8f07ebcf3fe93993a4a6a4831b015994cf447d9ca917e6b09003cb88458fe73 dist/assets/window/textures/font/furnace_art/0_4_0.png +cf889d1b39021bab30efcfdcd3a546705b858579a1d8519379508078cf5a5405 dist/assets/window/textures/font/furnace_art/0_5_0.png +20825923f2931a1fa318f5b8ab72231076fe07833b95cc929e75c60c116b6066 dist/assets/window/textures/font/furnace_art/0_6_0.png +da4997e38a6b9c98a9079860028736bee692e04044fbc2d1c34b44d0b983f9ab dist/assets/window/textures/font/furnace_art/1_0_0.png +7a60848c098c273d99a10912ddca6b73da438ad0fba8d504d74ed6c9b7ee620c dist/assets/window/textures/font/furnace_art/1_0_1.png +465dd7a9c1e24c5c6d12600e86fdcaa45c71279689db0568d9806bf666a4a01a dist/assets/window/textures/font/furnace_art/1_0_11.png +2081b2427d74e8025b5fcb57d0dc73c64f4b90b0cee6a223ad8ae9adae63e9ec dist/assets/window/textures/font/furnace_art/1_1_0.png +11d28d893b922c3aca2a79018ad990c1037e05bf3d19a519fbbb97024c872744 dist/assets/window/textures/font/furnace_art/1_1_1.png +396ce0b04bd2d50d7891d32ec3e50c6bcf02a4afd84d69a2bbc1e6c930873efc dist/assets/window/textures/font/furnace_art/1_2_0.png +df14c217af3b19286cfeb7793fbc3e2df06a4b346bab2e6abe08e06874b9638a dist/assets/window/textures/font/furnace_art/1_2_1.png +0a57b14cfc3534220eba53e91a256d28bf6d93bff9d923e8d46ff27852cdb5b2 dist/assets/window/textures/font/furnace_art/1_3_0.png +0dae6c0bd3c54f6e518b9353a03d54b8369e7da2ad2d6bc2f994952c55fd1439 dist/assets/window/textures/font/furnace_art/1_3_1.png +c7d6c6c02ff81a85dce74dc046a60fb519d72dab6f0f1228922b58ea0b3f909b dist/assets/window/textures/font/furnace_art/1_4_0.png +3b265e3a6a84231454196c0e13176b894ed269a8c1fcb441ff2567b82324228e dist/assets/window/textures/font/furnace_art/2_0_0.png +9440f04d489b687317646498fda4e5a6c96787bbde4e9af0aae443fc49bd8ae2 dist/assets/window/textures/font/furnace_art/2_1_0.png +b8a4799d9a4266224337e0ae965c051ae34e4d7d61f1037f55e91da1c33c064a dist/assets/window/textures/font/furnace_art/2_2_0.png +10d306d09e2f790a6a00de227978eb74a707bf5687edd96d8229fb9cd7d6f50e dist/assets/window/textures/font/furnace_art/2_3_0.png +1df617278bf51ae5631a3e013c3c869b80c19f355ba4d060cd9c6405d80bec9b dist/assets/window/textures/font/hud_vitals.png +e7436cf62bb11a34158b14d2ae0ad0c96f04f589ebbd87a097640678ada98007 dist/assets/window/textures/font/hud_vitals_art/0_0_0.png +a76a6399fb917f63adff850b2b0725bd5d34bdf190e4e8c0f2c1f23a7ac2ab18 dist/assets/window/textures/font/hud_vitals_art/0_1_0.png +a6e348b9f1e611dbfb3b9a6aabe26f50f2a26e30ae2a3cc709a254a33fe2af47 dist/assets/window/textures/font/hud_vitals_art/0_2_0.png +cbb36f5ed9c390208137758f2293520bc4833ec6baa82e28c1d1eac75e4d35db dist/assets/window/textures/font/hud_vitals_art/0_3_0.png +ce5a65538e40c6fb62938562c6222d47fe9a280b21c10a1d48c3268e9952aceb dist/assets/window/textures/font/hud_vitals_art/0_4_0.png +0afd58ef7897dddaa77df694330d0c29dbc4652a0e304f2618039b99125e956a dist/assets/window/textures/font/hud_vitals_art/0_5_0.png +f0e7baf37011cfab692fd325b670e619ad6ea7a6d7947d5d0cc3ced2270f0aca dist/assets/window/textures/font/hud_vitals_art/0_6_0.png +7e763ab3d0ff05e211c054c5ea2bc68fe5a45c58c8c67aad84e2ef72f3c4e5d3 dist/assets/window/textures/font/hud_vitals_art/1_0_0.png +3d868bec5e73cbda31caf63682560ebfa7f8dd7f023a2c6f7b9f18406ee7f2c1 dist/assets/window/textures/font/hud_vitals_art/1_0_1.png +7af2dd80c5391035c946d3578fd8bdca609c12341cde9a4bb3d22a6329e48162 dist/assets/window/textures/font/hud_vitals_art/1_1_0.png +16e7491da795ac3dae83546dcdc62d4cd59af9abc35aef84f8dab97c6f040872 dist/assets/window/textures/font/hud_vitals_art/1_1_1.png +b8b7bf7b32fbd743c48a2816e7fcce27a581e0b0c1195b194fb94fda6652b069 dist/assets/window/textures/font/hud_vitals_art/1_2_0.png +3baffa38b094b3bec34ebe4312983fd7ab23f5021d40a69f486eefb63bd8deea dist/assets/window/textures/font/hud_vitals_art/1_2_1.png +af46266e19ae2b8d29632487aff4019851c705aabaa8d74f44511f4859b46d74 dist/assets/window/textures/font/hud_vitals_art/1_3_0.png +e6ce966b562df77b564afa30501190d1f107f9b024a49e39d1b912dd133a60f5 dist/assets/window/textures/font/hud_vitals_art/1_3_1.png +14dd5fa44e171fd22dd59bae825c7c75fe8c27b36e9db818c932481b6133cdfd dist/assets/window/textures/font/hud_vitals_art/1_4_0.png +35cd879719bd04c972b7832423ee6679c5d70496bb0d6757d47b96a75f76b1e1 dist/assets/window/textures/font/hud_vitals_art/1_4_1.png +89a6ef9a216abdbb3de276225b4f9c28f37c33fdb07e94b0305d5beb1518255f dist/assets/window/textures/font/hud_vitals_art/1_5_0.png +a315ec04d848fdc68c433d998eb2f7e1be0ef6d6761d1c19bee75c2311d06381 dist/assets/window/textures/font/hud_vitals_art/1_6_0.png +eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png +40718eda3fa37228bc0aebfe8c42517ac0190643322aa5959355ce15c3ac78e2 dist/assets/window/window/debug.json +65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta +7404332df764a9c91c4ff2aac19e51239d1afd2702bfab0ddc8e89611fb5df3c kotlin-out/FurnaceView.kt +1c854ad85543622012b2e9e5764b5814d72fe4dfeb49aa59fb47036209cc5403 kotlin-out/VitalsHud.kt +6d89f74674d83e047816948623dc2dd03df8beb514f60ac77d0d8dabb89d2012 kotlin-out/WindowDefinitions.kt +f891bcf38496877d13e17ffaa87555fcb4dbe9a442187f53114270edc62a6dae kotlin-out/WindowEntries.kt +b370d891b2cbefcf6ca139faf98d1a290728cbf70cf2ddba0593dd6528aec45d kotlin-out/WindowFonts.kt +2f69951ebd8af79fcd55de6f7dcb2777636dc4e2bacabf86ca7326e32f249fcf kotlin-out/WindowHudDefinitions.kt +908138f8189899a4fbd0c49d394f15ca77adc65fe2eb15fe8a2ccaee8466bfeb kotlin-out/WindowHudEntries.kt +364cf8452541b1de6fa229eda3cc49329cc0039909d260314b82b8cbb17080fb kotlin-out/WindowPackData.kt +915b02477c962484d6bde1ffd4210939c32c264531dae1bb7ae3464b29d2f8a3 kotlin-out/WindowSpacers.kt +f3ef761f67dc574cb042376b69b49cde7b8d5ee9f05e23abc339ea53a825a29d kotlin-out/WindowSprites.kt diff --git a/plugin/test/golden/bar/ts/window/bars.tsx b/plugin/test/golden/bar/ts/window/bars.tsx new file mode 100644 index 0000000..27a79a0 --- /dev/null +++ b/plugin/test/golden/bar/ts/window/bars.tsx @@ -0,0 +1,32 @@ +import { Bar, Box, Hud, Switch, Text, Window, shape } from "plugin:window/ui"; +import { flag, fraction, tint } from "plugin:window/bind"; + +const tone = tint("tone", { ok: "#3ddc84", warn: "#ffb020", danger: "#ff4d4d" }); +const charge = fraction("charge", { shape: [2] }); + +const track = shape({ kind: "panel", fill: "#20242c", border_color: "#000000", border_width: 1, radius: 0 }); +/** White art, so a tint shows as its own color. */ +const fill = shape({ kind: "panel", fill: "#ffffff", border_width: 0, radius: 0, inset_depth: 0 }); +/** Art that differs along its width, so each clip is its own glyph. */ +const capsule = shape({ kind: "panel", fill: "#ffffff", border_color: "#b0b0b0", border_width: 1, radius: 3 }); + +// Bars in a window and in a shader-placed HUD: plain, tinted by a handle or a fixed color, and in a switch case. +export default ( + <> + + + + + + {{ true: , false: null }} + + + + + + Health + + + + +); diff --git a/plugin/test/jsx.test.ts b/plugin/test/jsx.test.ts index 80ba75a..80fcd5c 100644 --- a/plugin/test/jsx.test.ts +++ b/plugin/test/jsx.test.ts @@ -3,9 +3,9 @@ import { test } from "node:test"; import { Fragment, createElement, jsx } from "../.rpp/sdk/jsx.ts"; import { shape, texture } from "../src/ui/index.ts"; -import { builtin, collection, selection, sprite, text, tint, toggle, value } from "../src/bind/index.ts"; +import { builtin, collection, fraction, selection, sprite, text, tint, toggle, value } from "../src/bind/index.ts"; import * as raw from "../src/raw/index.ts"; -import { Box, Case, Collection, Hud, Image, Region, Section, Switch, Text, Window } from "../src/ui/components.ts"; +import { Bar, Box, Case, Collection, Hud, Image, Region, Section, Switch, Text, Window } from "../src/ui/components.ts"; test("JSX text defaults do not mutate reusable children", () => { const child = Box({ children: Text({ children: "Hello" }) }); @@ -131,6 +131,21 @@ test("a tint handle serializes its palette where a color goes", () => { assert.throws(() => tint("tone", { ok: "green" }), /entry `ok` must be a "#rrggbb" color/); }); +test("a bar serializes its fraction handle, art, and size", () => { + const fill = shape({ kind: "panel" }); + const hp = fraction("hp"); + assert.deepEqual(Bar({ bind: hp, width: 80, height: 6, fill, tint: "#ff5555", grow: 1 }), { + type: "bar", + handle: { kind: "fraction", id: "hp" }, + width: 80, + height: 6, + fill, + tint: "#ff5555", + layout: { grow: 1 }, + }); + assert.throws(() => Bar({ bind: text("hp") as never, width: 80, height: 6, fill }), /`fraction` handle/); +}); + test("indexed handles select one entry with at()", () => { const names = text("names", { shape: [2] }); assert.deepEqual(Text({ bind: names.at(1), width: 8 }), { diff --git a/plugin/test/jsx.typecheck.tsx b/plugin/test/jsx.typecheck.tsx index 6990a99..18b514e 100644 --- a/plugin/test/jsx.typecheck.tsx +++ b/plugin/test/jsx.typecheck.tsx @@ -4,6 +4,7 @@ import { builtin, collection, flag, + fraction, input, items, selection, @@ -16,7 +17,7 @@ import { import type { Indexed, TextHandle } from "../src/bind/index.ts"; import * as raw from "../src/raw/index.ts"; import { Prompt } from "../src/theme/industrial/index.ts"; -import { Box, Case, Hud, Image, Items, Region, Section, Switch, Text, Window } from "../src/ui/components.ts"; +import { Bar, Box, Case, Hud, Image, Items, Region, Section, Switch, Text, Window } from "../src/ui/components.ts"; const recess = shape({ kind: "slot", fill: "#102040" }); const price = text("price"); @@ -144,6 +145,40 @@ const fixedSlotTint = ; void [tinted, valueTint, flagColor, fixedSlotTint, tintedHud]; +const health = fraction("health"); +const charges = fraction("charge", { shape: [3] }); +const bars = ( + + + + {raw.bar(health, { width: 80, height: 6, fill: recess })} + +); +const barPreview = preview(handles, [ + [health, 0.42], + [charges, [0, 0.5, 1]], +]); +// @ts-expect-error A bar binds a fraction handle. +const flagBar = ; +// @ts-expect-error A bar needs its fill art. +const emptyBar = ; +// @ts-expect-error A bar's size is given in pixels. +const unsizedBar = ; +// @ts-expect-error A bar's tint is a color or a tint handle. +const valueTintBar = ; +// @ts-expect-error An indexed fraction is read through `.at()`. +const unindexedBar = ; +// @ts-expect-error A fraction takes a number. +const namedFraction = preview(handles, [[health, "half"]]); +void [bars, barPreview, flagBar, emptyBar, unsizedBar, valueTintBar, unindexedBar, namedFraction]; + const coin = texture("window/coin.png"); const bevel = shape({ kind: "button", fill: "#3a3a3a" }, { name: "industrial/button" }); const icon = sprite("icon", { only: ["lamp_on", "lamp_off"] }); From 68b3212fb0a7e50044ab5d2d1042db91076fb270 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:51:12 -0400 Subject: [PATCH 4/6] feat(example): add a budget bar to the shop and a wave bar to the status HUD The /bar debug command pins the fraction both draw. --- example/pack/src/window/art.ts | 16 ++++++++++ example/pack/src/window/handles.ts | 4 ++- example/pack/src/window/index.ts | 15 +++++++-- example/pack/src/window/shop.tsx | 15 +++++++-- example/pack/src/window/status_hud.tsx | 9 ++++-- .../com/chunkzero/window/example/BarPin.kt | 26 +++++++++++++++ .../com/chunkzero/window/example/Main.kt | 1 + .../com/chunkzero/window/example/MyShop.kt | 8 +++++ .../chunkzero/window/example/MyStatusHud.kt | 3 ++ .../window/example/generated/ShopView.kt | 4 +++ .../generated/StatusBottomCenterHud.kt | 4 +++ .../window/example/generated/WindowEntries.kt | 32 +++++++++++++++++-- .../example/generated/WindowHudEntries.kt | 30 +++++++++++++++-- 13 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 jvm/example/src/main/kotlin/com/chunkzero/window/example/BarPin.kt diff --git a/example/pack/src/window/art.ts b/example/pack/src/window/art.ts index 143ab47..97ef33c 100644 --- a/example/pack/src/window/art.ts +++ b/example/pack/src/window/art.ts @@ -47,6 +47,22 @@ export const toneLamp = shape({ inset_depth: 0, }); +/** The empty part of a bar. */ +export const barTrack = shape({ kind: "panel", fill: "#0a1433", border_width: 0, radius: 0, inset_depth: 0 }); + +/** A white bar fill, which a tint colors exactly. */ +export const toneFill = shape({ kind: "panel", fill: "#ffffff", border_width: 0, radius: 0, inset_depth: 0 }); + +/** A bar fill with a darker rim, so its right edge shows only once the bar is full. */ +export const budgetFill = shape({ + kind: "panel", + fill: "#ffb20b", + border_color: "#8d3c03", + border_width: 1, + radius: 0, + inset_depth: 0, +}); + export const icons = { back: texture("window/icons/back.png"), clear: texture("window/icons/clear.png"), diff --git a/example/pack/src/window/handles.ts b/example/pack/src/window/handles.ts index 4ca059a..71d4b22 100644 --- a/example/pack/src/window/handles.ts +++ b/example/pack/src/window/handles.ts @@ -1,4 +1,4 @@ -import { action, collection, flag, input, selection, text, tint, toggle } from "plugin:window/bind"; +import { action, collection, flag, fraction, input, selection, text, tint, toggle } from "plugin:window/bind"; // Shared by the shop and the status HUD export const tone = tint("tone", { ok: "#3ddc84", warn: "#ffb020", danger: "#ff4d4d" }); @@ -24,6 +24,8 @@ export const page = text("page"); export const selectedName = text("selected_name"); export const price = text("price"); export const hasPrice = flag("has_price"); +/** How much of the selected product's price the balance covers. */ +export const budget = fraction("budget"); export const search = action("search"); export const clearSearch = action("clear_search"); diff --git a/example/pack/src/window/index.ts b/example/pack/src/window/index.ts index 9e9ae98..22fbd81 100644 --- a/example/pack/src/window/index.ts +++ b/example/pack/src/window/index.ts @@ -4,7 +4,7 @@ import { art } from "plugin:window/theme/industrial"; import * as handles from "./handles.ts"; import search from "./search.tsx"; import shop from "./shop.tsx"; -import statusHuds, { coins, hint, power, rate } from "./status_hud.tsx"; +import statusHuds, { coins, hint, power, rate, waveCharge } from "./status_hud.tsx"; export default defineWindows({ sprites: { searchField: art.inputField }, @@ -23,6 +23,7 @@ export default defineWindows({ [handles.selectedName, "Amethyst Song"], [handles.price, "210"], [handles.hasPrice, true], + [handles.budget, 0.42], [handles.status, "30 items"], [handles.balance, "2500"], [handles.canBuy, true], @@ -37,13 +38,21 @@ export default defineWindows({ ], { scale: 3 }, ), - // The tinted hint in the tint's first palette entry, then in another. - preview(statusHuds[5], [[hint, "Next wave in 4s"]], { scale: 3 }), + // The tinted hint and wave bar in the tint's first palette entry, then in another. + preview( + statusHuds[5], + [ + [hint, "Next wave in 4s"], + [waveCharge, 0.6], + ], + { scale: 3 }, + ), preview( statusHuds[5], [ [hint, "Wave incoming"], [handles.tone, "danger"], + [waveCharge, 0.97], ], { name: "status_bottom_center_danger", scale: 3 }, ), diff --git a/example/pack/src/window/shop.tsx b/example/pack/src/window/shop.tsx index 8419b7c..3361e51 100644 --- a/example/pack/src/window/shop.tsx +++ b/example/pack/src/window/shop.tsx @@ -1,4 +1,4 @@ -import { Image, Switch, Text, containerLayout } from "plugin:window/ui"; +import { Bar, Image, Switch, Text, containerLayout } from "plugin:window/ui"; import type { ToggleHandle } from "plugin:window/bind"; import { Button, @@ -14,10 +14,11 @@ import { art, } from "plugin:window/theme/industrial"; -import { coin, icons, toneLamp, ventSlot } from "./art.ts"; +import { barTrack, budgetFill, coin, icons, toneLamp, ventSlot } from "./art.ts"; import { affordable, balance, + budget, buy, buyLabel, canBuy, @@ -107,6 +108,16 @@ export default ( + diff --git a/example/pack/src/window/status_hud.tsx b/example/pack/src/window/status_hud.tsx index f71725c..8875fcd 100644 --- a/example/pack/src/window/status_hud.tsx +++ b/example/pack/src/window/status_hud.tsx @@ -1,10 +1,10 @@ -import { Box, Hud, Image, Switch, Text } from "plugin:window/ui"; +import { Bar, Box, Hud, Image, Switch, Text } from "plugin:window/ui"; import type { TextProps } from "plugin:window/ui"; -import { flag, text } from "plugin:window/bind"; +import { flag, fraction, text } from "plugin:window/bind"; import type { TextHandle } from "plugin:window/bind"; import { Row, art } from "plugin:window/theme/industrial"; -import { coin, tonePill } from "./art.ts"; +import { barTrack, coin, toneFill, tonePill } from "./art.ts"; import { tone } from "./handles.ts"; export const coins = text("coins"); @@ -20,6 +20,8 @@ const objective = text("objective"); const stock = text("stock"); export const hint = text("hint"); const waveProgress = flag("wave_progress", { shape: [10] }); +/** How far the current wave's countdown has run. */ +export const waveCharge = fraction("wave_charge"); const white = "#ffffff"; const style: TextProps = { shadow: true, smallCaps: true, color: white }; @@ -89,6 +91,7 @@ export default [ + {Array.from({ length: 10 }, (_, i) => ( diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/BarPin.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/BarPin.kt new file mode 100644 index 0000000..b69c63d --- /dev/null +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/BarPin.kt @@ -0,0 +1,26 @@ +package com.chunkzero.window.example + +import net.minestom.server.MinecraftServer +import net.minestom.server.command.builder.Command +import net.minestom.server.command.builder.arguments.ArgumentType +import net.minestom.server.entity.Player +import net.minestom.server.tag.Tag + +private val BAR_TAG = Tag.Float("window_example_bar") + +/** The fraction [player] pinned with `/bar`, or `null` when each view fills its own bar. */ +internal fun pinnedBar(player: Player): Float? = player.getTag(BAR_TAG) + +/** + * Registers `/bar `, a debug command pinning how much of its bar the caller's status HUD fills + * from its next send, and the shops the caller opens afterwards; `auto` unpins it. + */ +internal fun registerBarCommand() { + val fraction = ArgumentType.Float("fraction").between(0f, 1f) + val command = Command("bar") + command.addSyntax({ sender, context -> + (sender as? Player)?.setTag(BAR_TAG, context.get(fraction)) + }, fraction) + command.addSyntax({ sender, _ -> (sender as? Player)?.removeTag(BAR_TAG) }, ArgumentType.Literal("auto")) + MinecraftServer.getCommandManager().register(command) +} diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/Main.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/Main.kt index aba31b8..2f0d08a 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/Main.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/Main.kt @@ -48,6 +48,7 @@ fun main() { registerPlayerEvents(instance, createPackRequest(packServer, market)) registerShopAccess(market) registerToneCommand() + registerBarCommand() server.start("0.0.0.0", serverPort) println("Window example server listening on $serverPort — pack served at $packUrl.") diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyShop.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyShop.kt index 9ecd1fe..8c99b31 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyShop.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyShop.kt @@ -56,6 +56,14 @@ class MyShop( override fun hasPrice(): Boolean = catalog.selected != null + /** How much of the selected product's price the balance covers, unless `/bar` pinned a fraction. */ + override fun budget(): Float { + val pinned = pinnedBar(player) + if (pinned != null) return pinned + val product = catalog.selected ?: return 0f + return coins.toFloat() / product.price + } + override fun hasQuery(): Boolean = query.isNotEmpty() override fun status(): Component { diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyStatusHud.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyStatusHud.kt index 41978c3..88fe886 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyStatusHud.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/MyStatusHud.kt @@ -77,6 +77,9 @@ class MyStatusBottomCenterHud( } } + /** Fills as the wave nears, unless `/bar` pinned a fraction. */ + override fun waveCharge(): Float = pinnedBar(player) ?: (1f - remaining().toFloat() / WAVE_MILLIS) + private fun remaining(): Long = WAVE_MILLIS - (System.currentTimeMillis() - startedAt) % WAVE_MILLIS override fun waveProgress(index: Int): Boolean = diff --git a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/ShopView.kt b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/ShopView.kt index 79bcfd2..b9dc256 100644 --- a/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/ShopView.kt +++ b/jvm/example/src/main/kotlin/com/chunkzero/window/example/generated/ShopView.kt @@ -21,6 +21,9 @@ public abstract class ShopView(protected val player: Player) : WindowView Date: Sun, 11 Oct 2026 02:51:12 -0400 Subject: [PATCH 5/6] docs: document bars --- docs/AUTHORING.md | 63 +++++++++++++++++++++++++++++++++++++++-------- docs/MANIFEST.md | 40 +++++++++++++++++++++--------- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index f50aa59..9209976 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -117,8 +117,8 @@ Window output: 534.9 KB (compiler output, before pack optimization) - Each window and HUD line is its total, split into its static art and its switch cases. The count is every case; a case that draws art bakes its own texture, so a switch costs roughly the number of such cases times one case's art. Cut cases, or move art that every case repeats out of the switch. -- A line also counts its art layers when it has any: the textures of art with a [`tint` handle](#tints) and of untinted - art drawn over it. +- A line also counts its art layers when it has any: the textures of art with a [`tint` handle](#tints), of untinted art + drawn over it, and of the clips of each [bar](#bars)'s fill. - Each distinct vertical position that a text slot (or line of a multi-line slot) draws at adds one text-row font document, and a custom font adds one per position. Sprite slots add one sprite font per position, listing every catalog sprite. Aligning slots to fewer rows removes fonts. HUDs pinned with [``](#roots) are aligned to @@ -181,6 +181,7 @@ Each entry's value is typed by its handle, and a handle the entry omits takes it | `value` | one of its values | its first value | | `selection` | one of its values | its `initial`, else its first value | | `tint` | one of its palette entry names | its first palette entry | +| `fraction` | a number from 0 to 1 | `1`: the bar is full | | `sprite` | a catalog sprite name, of its `only` | no sprite | | `items` | whether the slot holds an item | `true` | | `collection` | `{ count?, selected? }` | every cell holds an item; none is selected | @@ -343,6 +344,7 @@ Every window is built from a few primitives, which the [`industrial`](#industria | `` | `box` | flexbox or grid; `frame` is [inline art](#inline-art), `tint` colors it | | `` / `` | `text` | static text, or dynamic text bound by a `text` handle | | `` / `` | `image` | art drawn at its own size, or a runtime sprite slot bound by a handle | +| `` | `bar` | a [bar](#bars) filled up to the fraction a `fraction` handle returns | | `…` | `switchOn(on, cases)`, `case` | `bind` is a value, selection, flag, or toggle handle, or a condition | | `` | `region` | slots that take clicks and show a hitbox item | | ``, `` | `items`, `collection` | real item stacks | @@ -710,7 +712,8 @@ export const title = text("title"); | `selection(id, values, { initial? })` | ``, `Tabs bind`, `Choice bind`, `.is`, `.set` | `enum class Category`, `protected var category` and `onCategoryChanged` | | `text(id)` | `` | `protected abstract fun title(): Component` | | `sprite(id, { only? })` | `` | `protected abstract fun iconSprite(): WindowSprite?` | -| `tint(id, palette)` | `tint` of a box, case, or image; text `color` | `enum class Tone` and `protected abstract fun tone(): Tone` | +| `tint(id, palette)` | `tint` of a box, case, image, or bar; text `color` | `enum class Tone` and `protected abstract fun tone(): Tone` | +| `fraction(id)` | `` | `protected abstract fun health(): Float` | | `items(id)` | `` | `protected abstract fun stack(): ItemStack?` | | `collection(id, { selectable? })` | `` | `protected abstract val products: WindowCollection` | | `action(id)` | `onClick` of a region or control | `protected abstract fun onBuy(click: Click)` | @@ -737,10 +740,11 @@ export const title = text("title"); ### Indexed handles and repeaters -`flag`, `value`, `text`, `sprite`, `items`, and `action` take `shape: [n]` or `shape: [rows, columns]`. Read an entry -with `.at(i)` or `.at(row, column)`; the Kotlin member takes `index: Int` or `row: Int, column: Int`, and an indexed -action receives an `IndexedClick` whose `index` is the entry, row-major. The view exposes each shape as constants: -`ShopView.ENTRY_STACK_SIZE`, or `_ROWS` and `_COLUMNS`. Entries are named `entry_stack[3]` or `strokes[1][4]`. +`flag`, `value`, `text`, `sprite`, `fraction`, `items`, and `action` take `shape: [n]` or `shape: [rows, columns]`. Read +an entry with `.at(i)` or `.at(row, column)`; the Kotlin member takes `index: Int` or `row: Int, column: Int`, and an +indexed action receives an `IndexedClick` whose `index` is the entry, row-major. The view exposes each shape as +constants: `ShopView.ENTRY_STACK_SIZE`, or `_ROWS` and `_COLUMNS`. Entries are named `entry_stack[3]` or +`strokes[1][4]`. ```tsx export const pickEntry = action("pick_entry", { shape: [6] }); @@ -793,6 +797,43 @@ override fun tone(): Tone = if (remaining > 3) Tone.OK else Tone.DANGER shader's table, so a three-color tint on a frame and its text costs six. Menus and HUDs without a shader send the palette color itself and cost nothing more. +### Bars + +`` draws a progress bar: its `fill` art, revealed from the left up to the fraction a `fraction` handle returns. + +```tsx +import { fraction, tint } from "plugin:window/bind"; + +const health = fraction("health"); +const tone = tint("tone", { ok: "#3ddc84", low: "#ff4d4d" }); + +; +``` + +```kotlin +override fun health(): Float = player.health / player.maxHealth + +override fun tone(): Tone = if (health() > 0.25f) Tone.OK else Tone.LOW +``` + +- `width` and `height` are pixels, each from 1 to 256. `fill` and the optional `track` are [inline art](#inline-art), + stretched over the bar like a box's `frame`; the track is drawn whole, under the fill. +- The fraction is clamped to 0 through 1 and rounded to the nearest whole column of `width`, so an 80 pixel bar has 81 + states. The view redraws only when the column count changes. +- The fill is revealed, not rescaled: a fraction draws the leftmost columns of the full-width art exactly as they look + in the full bar. Rounded ends, borders, gradients, and textures all work; the right end of the art shows only when the + bar is full, so give the fill square ends unless that is the look you want. +- `tint` multiplies the fill like any other [tint](#tints): a hex color is baked in, and a `tint` handle picks the color + at runtime. Color thresholds are a tint handle whose entry Kotlin picks from the same value, as above; there is no + separate thresholds option. +- A bar works in windows and HUDs, including shader-placed HUDs and switch cases. Its fill is an art layer, drawn at its + place in the [draw order](#draw-order). In an `anvil` window with an ``, a bar needs + `experimental.anvilUpdates`, like any other title change. +- Cost: the fill is cut into clips of 1, 2, 4, … columns, and each column count is drawn from at most one clip per size. + Clips that look the same share a texture, so a fill that is uniform along its width costs one small texture per size, + 7 for a 100 pixel bar. Art that differs from column to column costs up to one texture per column, so keep gradients + and textured fills narrow, or uniform in the middle. + ## Raw API `raw` builds the same primitives as JSX with plain function calls, positioned by hand or laid out by `box` and @@ -883,6 +924,7 @@ is not enabled. Core shaders are intentionally opt-in; the fallback channel rema | `box(opts)` | none | `x`, `y`, `frame`, `tint`, `style`, `theme`, `children`; see [Box layout](#box-layout) | | `text(content, opts)` | text, or a `text` handle | `x`, `y`, `width`, `align`, `color`, `shadow`, `bold`, `italic`, `underlined`, `strikethrough`, `obfuscated`, `font`, `small_caps`; bound text also fits | | `image(art, opts)` | inline art; or a `sprite` handle with `width` and `height` | `x`, `y`, `tint`; with a handle `align` (`"left"`, `"center"`, `"right"`) | +| `bar(handle, opts)` | a `fraction` handle; `width`, `height`, and `fill` | `x`, `y`, `track`, `tint`; see [Bars](#bars) | | `region(opts)` | none; fills its box, or set `width` and `height` | `on_click` (a click handle), `tooltip`, `item_model` | | `items(handle, opts)` | an `items` handle; outside a section or box `slots`, `pattern`, `transform` | none | | `collection(handle, opts)` | a `collection` handle; outside a section `slots`, `pattern`, `transform` | `frame`, `selected_sprite`, `action: false` for display-only collections | @@ -927,9 +969,10 @@ Window draws the static chrome first, then every runtime layer in authored tree switch case art, art layers, and collection selections. Later elements draw above earlier ones, so anything authored after a switch draws above its case art. Elements in a box draw in authored order whether they flow or set `x`/`y`. -Art with a [`tint` handle](#tints) is an art layer, so it draws above the static chrome and above the text and switches -authored before it. Untinted art authored after it that overlaps it becomes an art layer too and draws above it; all -other untinted art stays in the static chrome underneath. Inside a switch case the same holds within the case's art. +Art with a [`tint` handle](#tints) and the fill of a [bar](#bars) are art layers, so they draw above the static chrome +and above the text and switches authored before them. Untinted art authored after one that overlaps it becomes an art +layer too and draws above it; all other untinted art stays in the static chrome underneath. Inside a switch case the +same holds within the case's art. `WindowView.list(...)` creates a `WindowList` that pages or scrolls a reactive list through a fixed number of cells and keeps its selection by key; its `items(...)` adapts it to a `collection` handle, and `canPrevious()`/`canNext()` back diff --git a/docs/MANIFEST.md b/docs/MANIFEST.md index eb29733..3646839 100644 --- a/docs/MANIFEST.md +++ b/docs/MANIFEST.md @@ -280,10 +280,21 @@ switch on its handle with one region per state. }, // Optional. Art drawn as a layer of its own: art tinted from a palette, - // and untinted art authored over it. `static` is net-zero like the window's. + // untinted art authored over it, and the fill of a bar. `static` is net-zero + // like the window's. "art": { "art/0": { "static": "󰀀…", "tint": "tone" }, "art/1": { "static": "󰀀…" }, + "art/2": { + "static": "󰀀…", + "bar": { + "name": "health", + "x": 8, + "width": 5, + "levels": ["\uE010\uE010\uE011", "\uE012", "\uE013"], + "advances": { "\uE010": 2, "\uE011": 2, "\uE012": 3, "\uE013": 5 }, + }, + }, }, // Optional. The palettes that `tint` fields name: entry name to color. @@ -436,16 +447,16 @@ Notes: - `inputs` describes native inventory input metadata. It is currently valid only for `surface.container = "anvil"`; exactly one input owns container slot `0`, whose item name drives the vanilla edit field. - `handles` is optional metadata for codegen on windows and HUDs; runtimes ignore it. `kind` is `flag`, `toggle`, - `value`, `selection`, `text`, `sprite`, `tint`, `items`, `collection`, `action`, `input`, or `builtin`. `values` lists - a value or selection's values or a tint's entries, `colors` a tint's color per entry, `shape` an indexed handle's - extents, `initial` a toggle's (`"true"`/`"false"`) or selection's initial value, `selectable` whether a collection - marks a selected cell, and `only` the sprites a sprite handle returns, which codegen generates as an enum of those - `WindowSprite` constants. Each use names the entry it binds: a `slot`, `sprite_slot`, `item`, `collection`, `input`, - `switch`, a region's `click`, or the palette a `tint` handle picks from. `at` is the index read from an indexed - handle, and `value` the value a condition compares with or a click sets. A use is keyed `{id}`, then `[{i}]` per - index, `={value}` for a click that sets a value, or `?{value}` for a condition, with `~2`, `~3`, … appended to later - uses of the same key. Codegen generates members only from handles and binds every use to its handle's member; an entry - no handle uses gets no member. + `value`, `selection`, `text`, `sprite`, `tint`, `fraction`, `items`, `collection`, `action`, `input`, or `builtin`. + `values` lists a value or selection's values or a tint's entries, `colors` a tint's color per entry, `shape` an + indexed handle's extents, `initial` a toggle's (`"true"`/`"false"`) or selection's initial value, `selectable` whether + a collection marks a selected cell, and `only` the sprites a sprite handle returns, which codegen generates as an enum + of those `WindowSprite` constants. Each use names the entry it binds: a `slot`, `sprite_slot`, `item`, `collection`, + `input`, `switch`, a region's `click`, the palette a `tint` handle picks from, or the `bar` a `fraction` handle fills. + `at` is the index read from an indexed handle, and `value` the value a condition compares with or a click sets. A use + is keyed `{id}`, then `[{i}]` per index, `={value}` for a click that sets a value, or `?{value}` for a condition, with + `~2`, `~3`, … appended to later uses of the same key. Codegen generates members only from handles and binds every use + to its handle's member; an entry no handle uses gets no member. - `switches` is optional on windows and HUDs. At most one case of a switch is active: the case whose `value` the switch's binding returns, or none until it is bound. A switch named in a case's `switches` is nested: it and everything in its cases are active only while that case is. A case lists only the entries directly inside it. The @@ -465,6 +476,13 @@ Notes: into art and sprites. Every palette must be bound. A tinted slot's `color` is its palette's first authored entry, and runtimes ignore it. Art and sprites without `tint` are sent in white. A case lists the art layers directly inside it in `art`, drawn only while the case is active. +- `art.*.bar` marks the fill of a bar, which the runtime draws up to the fraction bound to `bar.name` instead of + `static` (the whole fill). The columns drawn are `round(clamp(fraction, 0, 1) * width)`. `levels[k]` holds one glyph + of `font` per clip of `2^k` columns: its character `i` draws the fill's columns from `i << (k + 1)`, and a space is a + clip with nothing to draw. To draw `n` columns, take the set bits of `n` from the highest: each draws the clip of that + size starting where the previous one ended, placed `x` plus that start right of where `static` starts, and advances + the cursor by its glyph's `advances` entry. The runtime returns the cursor to the start afterwards. Every bar must be + bound. - In a shader HUD, `art.*.tint_markers` and `slots.*.tint_markers` map each palette entry to the marker color the runtime sends in place of the palette color; the generated shader restores that entry's color. Untinted art layers use `shader.static_marker`. Outside shader HUDs `tint_markers` is absent and the runtime sends the palette color. From bf254e1db78a30342fbee10a2a410de6f5ba6ea2 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:00:03 -0400 Subject: [PATCH 6/6] fix(previews): round bar columns in single precision like the runtime --- crates/window-core/src/ir.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/window-core/src/ir.rs b/crates/window-core/src/ir.rs index 52cad5e..7129d3a 100644 --- a/crates/window-core/src/ir.rs +++ b/crates/window-core/src/ir.rs @@ -481,9 +481,9 @@ pub struct ArtIr { } /// The columns a bar of `width` columns draws at `fraction`: the nearest whole column, with `fraction` clamped to -/// 0 through 1. +/// 0 through 1. Computed in single precision, as the runtime does. pub fn bar_columns(fraction: f64, width: u32) -> u32 { - (fraction.clamp(0.0, 1.0) * f64::from(width)).round() as u32 + ((fraction as f32).clamp(0.0, 1.0) * width as f32).round() as u32 } /// A runtime-selected group of cases, of which at most one is active. @@ -604,6 +604,14 @@ pub struct LaidOutHud { mod tests { use super::*; + #[test] + fn bar_columns_round_in_single_precision() { + // 0.29 * 50 is 14.499… in double precision and 14.5 in single. + assert_eq!(bar_columns(0.29, 50), 15); + assert_eq!(bar_columns(1.5, 256), 256); + assert_eq!(bar_columns(-1.0, 256), 0); + } + #[test] fn rgb_hex_round_trip() { let c = Rgb::parse_hex("#FFd700").unwrap();