From 29f93a98adb2e045374bd6bf4f3943bf003595d9 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:13:40 -0400 Subject: [PATCH 1/2] feat: bring repeaters, paging, and tooltips to full-screen windows Repeater cells are separate canvas controls with their own hit-test, hover outline, and indexed action. FullscreenView.list pages a list through them like WindowView.list. Region tooltips are laid out by the compiler beside their control, below it or above it where the canvas ends, as a vanilla-styled panel of plain text. The session sends the hovered control's tooltip in two layers reserved below the overlay. Identical panels share a texture. Real items, sections, runtime sprites, and hitbox item models stay container-only and fail the build with errors that say why. The industrial Repeater sizes its cells at 18px per slot outside sections too. --- crates/window-core/src/codegen/definitions.rs | 5 +- crates/window-core/src/codegen/entries.rs | 26 ++++-- crates/window-core/src/fullscreen.rs | 11 ++- crates/window-core/src/ir.rs | 13 +++ crates/window-core/src/layout/controls.rs | 9 +- crates/window-core/src/layout/flex.rs | 7 +- crates/window-core/src/layout/mod.rs | 10 +++ crates/window-core/src/layout/target.rs | 16 ++++ crates/window-core/src/layout/tooltips.rs | 86 +++++++++++++++++++ crates/window-core/src/manifest.rs | 4 +- crates/window-core/src/manifest/fullscreen.rs | 14 +++ crates/window-core/src/pipeline/fullscreen.rs | 61 ++++++++++++- crates/window-core/src/pipeline/glyphs.rs | 6 +- .../src/pipeline/tests/fullscreens.rs | 74 ++++++++++++++-- docs/AUTHORING.md | 20 +++++ .../chunkzero/window/FullscreenDefinition.kt | 16 ++++ .../com/chunkzero/window/FullscreenSession.kt | 12 ++- .../com/chunkzero/window/FullscreenView.kt | 16 ++++ .../kotlin/com/chunkzero/window/WindowList.kt | 2 +- .../chunkzero/window/internal/HudComposer.kt | 15 +++- .../window/manifest/FullscreenEntries.kt | 13 +++ .../chunkzero/window/FullscreenManifests.kt | 61 +++++++++++-- .../chunkzero/window/FullscreenSessionTest.kt | 45 ++++++++++ plugin/src/theme/industrial/slots.tsx | 12 ++- plugin/test/golden/fullscreen/expected.sha256 | 27 ++++-- .../golden/fullscreen/ts/window/index.tsx | 11 ++- 26 files changed, 537 insertions(+), 55 deletions(-) create mode 100644 crates/window-core/src/layout/tooltips.rs diff --git a/crates/window-core/src/codegen/definitions.rs b/crates/window-core/src/codegen/definitions.rs index 58f2e78f..6b7de4a5 100644 --- a/crates/window-core/src/codegen/definitions.rs +++ b/crates/window-core/src/codegen/definitions.rs @@ -208,10 +208,11 @@ pub(super) fn generate_fullscreen_entries(manifest: &Manifest, package_name: &st let switches = any(&|f| !f.switches.is_empty()); let layers = any(&|f| !f.layers.is_empty()); let imports = [ - (any(&|f| !f.slots.is_empty()), "Align"), + (any(&|f| !f.slots.is_empty() || f.controls.values().any(|control| control.tooltip.is_some())), "Align"), (any(&|f| !f.art.is_empty()), "ArtEntry"), (any(&|f| f.art.values().any(|art| art.bar.is_some())), "BarEntry"), (any(&|f| !f.controls.is_empty()), "ControlEntry"), + (any(&|f| f.controls.values().any(|control| control.tooltip.is_some())), "ControlTooltipEntry"), (true, "CursorEntry"), (true, "FullscreenCanvasEntry"), (true, "FullscreenEntry"), @@ -219,7 +220,7 @@ pub(super) fn generate_fullscreen_entries(manifest: &Manifest, package_name: &st (true, "FullscreenSurfaceEntry"), (layers, "LayerEntry"), (layers, "LayerKind"), - (any(&|f| !f.slots.is_empty()), "SlotEntry"), + (any(&|f| !f.slots.is_empty() || f.controls.values().any(|control| control.tooltip.is_some())), "SlotEntry"), (any_slot(&|slot| slot.lines.is_some()), "SlotLinesEntry"), (switches, "SwitchCaseEntry"), (switches, "SwitchEntry"), diff --git a/crates/window-core/src/codegen/entries.rs b/crates/window-core/src/codegen/entries.rs index 413ceec7..26bd5f1d 100644 --- a/crates/window-core/src/codegen/entries.rs +++ b/crates/window-core/src/codegen/entries.rs @@ -4,10 +4,10 @@ use std::collections::BTreeMap; use crate::ir::{FullscreenMode, Hitbox, Layer, Tooltip}; use crate::manifest::{ - AnvilInputEntry, ArtEntry, BarEntry, CollectionEntry, ControlEntry, FontMetricsEntry, FullscreenCanvasEntry, - FullscreenSurfaceEntry, HoverEntry, HudShaderEntry, HudSurfaceEntry, ItemEntry, RegionEntry, SlotAreaEntry, - SlotEntry, SlotLinesEntry, SlotRefEntry, SpriteEntry, SpriteSlotEntry, SurfaceEntry, SwitchCaseEntry, SwitchEntry, - TextOverflow, + AnvilInputEntry, ArtEntry, BarEntry, CollectionEntry, ControlEntry, ControlTooltipEntry, FontMetricsEntry, + FullscreenCanvasEntry, FullscreenSurfaceEntry, HoverEntry, HudShaderEntry, HudSurfaceEntry, ItemEntry, RegionEntry, + SlotAreaEntry, SlotEntry, SlotLinesEntry, SlotRefEntry, SpriteEntry, SpriteSlotEntry, SurfaceEntry, + SwitchCaseEntry, SwitchEntry, TextOverflow, }; use super::literals::{ @@ -46,7 +46,7 @@ pub(super) fn fullscreen_surface_entry_expr(surface: &FullscreenSurfaceEntry, le } pub(super) fn control_entry_expr(control: &ControlEntry, level: usize) -> String { - Call::new("ControlEntry", level) + let call = Call::new("ControlEntry", level) .arg("index", control.index) .arg("x", control.x) .arg("y", control.y) @@ -55,8 +55,20 @@ pub(super) fn control_entry_expr(control: &ControlEntry, level: usize) -> String .arg("action", optional_string_expr(control.action.as_ref())) .arg("defaultAction", optional_string_expr(control.default_action.as_ref())) .arg("hover", control.hover) - .arg("outline", optional_string_expr(control.outline.as_ref())) - .arg("source", optional_string_expr(control.source.as_ref())) + .arg("outline", optional_string_expr(control.outline.as_ref())); + let call = match &control.tooltip { + Some(tooltip) => call.arg("tooltip", control_tooltip_entry_expr(tooltip, level + 1)), + None => call, + }; + call.arg("source", optional_string_expr(control.source.as_ref())).finish() +} + +fn control_tooltip_entry_expr(tooltip: &ControlTooltipEntry, level: usize) -> String { + let lines = tooltip.lines.iter().map(|line| slot_entry_expr(line, level + 2)); + Call::new("ControlTooltipEntry", level) + .arg("panel", kt_string(&tooltip.panel)) + .arg("marker", kt_string(&tooltip.marker)) + .arg("lines", list_of(lines, level + 1)) .finish() } diff --git a/crates/window-core/src/fullscreen.rs b/crates/window-core/src/fullscreen.rs index 8dc38189..039eb716 100644 --- a/crates/window-core/src/fullscreen.rs +++ b/crates/window-core/src/fullscreen.rs @@ -73,9 +73,16 @@ pub const BACKDROP_LAYER: u8 = 0; pub const STATIC_LAYER: u8 = 1; /// The highest layer of a window's runtime layers, which take the layers from `STATIC_LAYER + 1` in authored order. -pub const MAX_CONTENT_LAYER: u8 = 253; +pub const MAX_CONTENT_LAYER: u8 = 251; -/// The layer of the runtime's own overlays, such as server-driven hover outlines, above every window layer. +/// The layer of the hovered control's tooltip panel, above every window layer. +pub const TOOLTIP_LAYER: u8 = 252; + +/// The layer of the hovered control's tooltip text, above its panel. +pub const TOOLTIP_TEXT_LAYER: u8 = 253; + +/// The layer of the runtime's own overlays, such as server-driven hover outlines, above every window layer and +/// tooltip. pub const OVERLAY_LAYER: u8 = 254; /// The layer of the cursor, above everything else. diff --git a/crates/window-core/src/ir.rs b/crates/window-core/src/ir.rs index 7ce5cacf..29274353 100644 --- a/crates/window-core/src/ir.rs +++ b/crates/window-core/src/ir.rs @@ -645,12 +645,25 @@ pub struct LaidOutFullscreen { pub art: Vec, /// Runtime layers in authored tree order. pub layers: Vec, + /// The tooltips of [`Self::regions`], each drawn while its region is hovered. + pub tooltips: Vec, /// Typed handles by id. pub handles: BTreeMap, /// Non-fatal findings to surface to the user. pub warnings: Vec, } +/// A full-screen region's tooltip, laid out on the canvas beside the region. +#[derive(Clone, Debug)] +pub struct TooltipIr { + /// The region's index in [`LaidOutFullscreen::regions`]. + pub region: usize, + /// The panel drawn behind the text. + pub panel: Rect, + /// The title, then each line, as static labels over the panel. + pub lines: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/window-core/src/layout/controls.rs b/crates/window-core/src/layout/controls.rs index e6f030d0..00006a10 100644 --- a/crates/window-core/src/layout/controls.rs +++ b/crates/window-core/src/layout/controls.rs @@ -70,10 +70,11 @@ impl Solver<'_, T> { None => format!("region `{name}`"), }; self.check_inside_bounds(rect, &source)?; - if self.target.container_kind().is_none() && (region.tooltip.is_some() || region.item_model.is_some()) { - return Err(self - .target - .layout_err(format!("{source} sets `tooltip` or `item_model`, which need inventory slots"))); + if self.target.container_kind().is_none() && region.item_model.is_some() { + return Err(self.target.layout_err(format!( + "{source} sets `item_model`, which draws the hitbox item of inventory slots, and a {} has none", + self.target.kind() + ))); } let hitbox = (region.tooltip.is_some() || region.item_model.is_some()) .then(|| Hitbox { item_model: region.item_model.clone(), tooltip: region.tooltip.clone() }); diff --git a/crates/window-core/src/layout/flex.rs b/crates/window-core/src/layout/flex.rs index e5a58257..f40ff646 100644 --- a/crates/window-core/src/layout/flex.rs +++ b/crates/window-core/src/layout/flex.rs @@ -395,10 +395,9 @@ impl Solver<'_, T> { /// Lays a section's children out on a grid with one track per slot, then places each child on its area. pub(super) fn place_section(&mut self, section: &SlotSection) -> Result { let label = section_name(section); - let kind = self - .target - .container_kind() - .ok_or_else(|| self.target.layout_err(format!("{label} sections are only available in windows")))?; + let kind = self.target.container_kind().ok_or_else(|| { + self.target.layout_err(format!("{label} sections are only available in container windows")) + })?; let dims = kind.section_size(section.section); let bounds = kind.section_bounds(section.section); if let Some(frame) = §ion.frame { diff --git a/crates/window-core/src/layout/mod.rs b/crates/window-core/src/layout/mod.rs index caa65561..6c410461 100644 --- a/crates/window-core/src/layout/mod.rs +++ b/crates/window-core/src/layout/mod.rs @@ -16,6 +16,7 @@ mod flex; mod measure; mod slots; mod target; +mod tooltips; mod visuals; use std::collections::HashSet; @@ -180,6 +181,14 @@ fn solve_fullscreen( let mut solver = Solver::new(project, texture_size, fonts, target); solver.place_children(&fullscreen.children, canvas)?; let Solved { draws, slots, regions, switches, art, layers, warnings, .. } = solver.finish(); + let tooltips = regions + .iter() + .enumerate() + .filter_map(|(index, region)| { + let tooltip = region.hitbox.as_ref()?.tooltip.as_ref()?; + Some(tooltips::tooltip(&target, index, region, tooltip)) + }) + .collect::>()?; Ok(LaidOutFullscreen { name: fullscreen.name.clone(), @@ -191,6 +200,7 @@ fn solve_fullscreen( switches, art, layers, + tooltips, handles: fullscreen.handles.clone(), warnings, }) diff --git a/crates/window-core/src/layout/target.rs b/crates/window-core/src/layout/target.rs index 4ff28d4f..5d988f31 100644 --- a/crates/window-core/src/layout/target.rs +++ b/crates/window-core/src/layout/target.rs @@ -234,4 +234,20 @@ impl LayoutTarget for FullscreenTarget<'_> { fn duplicate_namespace(&self) -> &'static str { "slot/region/label names share one namespace" } + + fn sprite_slot_err(&self, name: &str) -> Error { + self.layout_err(format!( + "fullscreen `{}` cannot contain runtime sprite slot `{name}`: full-screen windows draw no runtime \ + sprites; switch between static images with a `` instead", + self.name + )) + } + + fn inventory_err(&self, name: &str) -> Error { + self.layout_err(format!( + "fullscreen `{}` cannot contain inventory element `{name}`: real items need inventory slots, which only \ + container windows have; draw item art with an image instead", + self.name + )) + } } diff --git a/crates/window-core/src/layout/tooltips.rs b/crates/window-core/src/layout/tooltips.rs new file mode 100644 index 00000000..55eba267 --- /dev/null +++ b/crates/window-core/src/layout/tooltips.rs @@ -0,0 +1,86 @@ +//! Full-screen tooltips: a vanilla-styled panel of plain text, laid out beside its region. + +use super::target::LayoutTarget; +use crate::Result; +use crate::geometry::{Point, Rect, Size}; +use crate::ir::{Align, RegionIr, Rgb, SlotIr, Tooltip, TooltipIr}; +use crate::model::TextFit; +use crate::vanilla; + +/// The title's color, as in a container tooltip. +const TITLE_COLOR: Rgb = Rgb::new(0xff, 0xff, 0xff); +/// The other lines' color, as in a container tooltip. +const LINE_COLOR: Rgb = Rgb::new(0xaa, 0xaa, 0xaa); +/// The distance from the panel's edge to its text: a 1px rim, a 1px border, and 2px of padding. +const INSET: u32 = 4; +/// Vanilla's tooltip line pitch, and the gap it leaves below the title. +const LINE_PITCH: u32 = 10; +const TITLE_GAP: u32 = 2; +/// The gap between the region and the panel. +const GAP: i32 = 2; + +/// The tooltip of `regions[index]`, centered below its region, or above it where the canvas ends first, and kept on +/// the canvas. +pub(super) fn tooltip( + target: &impl LayoutTarget, + index: usize, + region: &RegionIr, + tooltip: &Tooltip, +) -> Result { + let texts: Vec<&str> = + std::iter::once(tooltip.title.as_str()).chain(tooltip.lines.iter().map(String::as_str)).collect(); + if texts.iter().any(|text| text.contains('<')) { + return Err(target.layout_err(format!( + "{} has a tooltip with `<`: full-screen tooltips draw plain text, without MiniMessage tags", + region.source + ))); + } + let measure = |text: &str| vanilla::visible_width(text, false, vanilla::advance, vanilla::glyph_width); + let text_width = texts.iter().map(|text| measure(text)).max().unwrap_or(0); + let extra = texts.len() as u32 - 1; + let text_height = 8 + if extra > 0 { TITLE_GAP + extra * LINE_PITCH } else { 0 }; + let size = Size::new(text_width + 2 * INSET, text_height + 2 * INSET); + let canvas = target.bounds(); + if size.width > canvas.width || size.height > canvas.height { + return Err(target.layout_err(format!( + "{} has a {}x{} tooltip, which does not fit the {}x{} canvas", + region.source, size.width, size.height, canvas.width, canvas.height + ))); + } + let rect = region.rect; + let (width, height) = (size.width as i32, size.height as i32); + let below = rect.y + rect.height as i32 + GAP; + let above = rect.y - GAP - height; + let y = if below + height <= canvas.height as i32 || above < 0 { below } else { above }; + let x = rect.x + (rect.width as i32 - width) / 2; + let panel = Rect::from_parts( + Point::new(x.clamp(0, canvas.width as i32 - width), y.clamp(0, canvas.height as i32 - height)), + size, + ); + let lines = texts + .iter() + .enumerate() + .map(|(line, text)| { + let top = if line == 0 { 0 } else { TITLE_GAP + line as u32 * LINE_PITCH }; + let origin = Point::new(panel.x + INSET as i32, panel.y + (INSET + top) as i32); + SlotIr { + name: format!("{}/tooltip/{line}", region.name), + text: Some(text.to_string()), + rect: Rect::from_parts(origin, Size::new(measure(text), 8)), + align: Align::Left, + color: if line == 0 { TITLE_COLOR } else { LINE_COLOR }, + shadow: false, + bold: false, + italic: false, + underlined: false, + strikethrough: false, + obfuscated: false, + font: None, + fit: TextFit::default(), + tint: None, + source: Some(region.source.clone()), + } + }) + .collect(); + Ok(TooltipIr { region: index, panel, lines }) +} diff --git a/crates/window-core/src/manifest.rs b/crates/window-core/src/manifest.rs index 0276c016..370b003e 100644 --- a/crates/window-core/src/manifest.rs +++ b/crates/window-core/src/manifest.rs @@ -15,7 +15,9 @@ use crate::{Error, Result}; mod fullscreen; mod hud; -pub use fullscreen::{ControlEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry}; +pub use fullscreen::{ + ControlEntry, ControlTooltipEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry, +}; pub use hud::{HudEntry, HudShaderEntry, HudSurfaceEntry}; /// Current compiled definition schema version. diff --git a/crates/window-core/src/manifest/fullscreen.rs b/crates/window-core/src/manifest/fullscreen.rs index 927f3dcd..01904d51 100644 --- a/crates/window-core/src/manifest/fullscreen.rs +++ b/crates/window-core/src/manifest/fullscreen.rs @@ -81,11 +81,25 @@ pub struct ControlEntry { /// while the cursor is over the control. Present when [`Self::hover`] is set. #[serde(default, skip_serializing_if = "Option::is_none")] pub outline: Option, + /// The control's tooltip, which the runtime sends while the cursor is over the control. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tooltip: Option, /// The authored element this control comes from, such as ``region `buy` ``, for diagnostics. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, } +/// A full-screen control's tooltip: a panel beside the control and its plain-text lines. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlTooltipEntry { + /// The panel, net-zero from the line origin. + pub panel: String, + /// The marker color [`Self::panel`] is sent in. + pub marker: String, + /// The title, then each line, as static labels sent in their `shader_marker`. + pub lines: Vec, +} + /// The full-screen canvas contract of [`crate::fullscreen`], which the runtime and the generated shaders share. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FullscreenCanvasEntry { diff --git a/crates/window-core/src/pipeline/fullscreen.rs b/crates/window-core/src/pipeline/fullscreen.rs index dac857a7..015e8b43 100644 --- a/crates/window-core/src/pipeline/fullscreen.rs +++ b/crates/window-core/src/pipeline/fullscreen.rs @@ -1,17 +1,20 @@ //! Per-full-screen-window compilation and the full-screen canvas contract: its include and built-in glyphs. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::hash_map::Entry; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::authoring::HudGroup; use crate::compose::{Composite, Texture}; use crate::font::bitmap_provider; use crate::fullscreen::{ self, BACKDROP_LAYER, CURSOR_LAYER, MAX_COLORS, MAX_CONTENT_LAYER, Marker, MarkerKind, OVERLAY_LAYER, STATIC_LAYER, + TOOLTIP_LAYER, TOOLTIP_TEXT_LAYER, }; use crate::geometry::{Point, Rect, Size}; use crate::ir::{LaidOutFullscreen, Layer, RegionIr, Rgb, tint_palette, tint_palettes}; use crate::manifest::{ - ControlEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry, SlotEntry, + ControlEntry, ControlTooltipEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry, + SlotEntry, }; use crate::size::{Pane, PaneKind, Part}; use crate::{Error, Result, bake}; @@ -134,6 +137,28 @@ pub(super) fn compile_fullscreen( let text = ctx.bake_composite(&outline, origin, pane, &file, key, Part::Hover)?; outline_texts.insert(index, text); } + let mut tooltips = BTreeMap::new(); + if !f.tooltips.is_empty() { + let marker = contract.marker(MarkerKind::Content, TOOLTIP_LAYER, WHITE)?; + let mut panels: HashMap = HashMap::new(); + for (tooltip, (key, panel)) in f.tooltips.iter().zip(tooltip_panels(f)) { + let panel = match panels.entry(tooltip.panel) { + Entry::Occupied(baked) => baked.get().clone(), + Entry::Vacant(entry) => { + let Rect { x, y, width, height } = tooltip.panel; + let file = format!("{file_base}_tooltip_{x}_{y}_{width}x{height}"); + entry.insert(ctx.bake_composite(&panel, origin, pane, &file, key, Part::Hover)?).clone() + } + }; + let mut lines = Vec::with_capacity(tooltip.lines.len()); + for line in &tooltip.lines { + let mut entry = slot_entry(ctx, line, line.rect.y, None)?; + entry.shader_marker = Some(contract.marker(MarkerKind::Content, TOOLTIP_TEXT_LAYER, line.color)?); + lines.push(entry); + } + tooltips.insert(tooltip.region, ControlTooltipEntry { panel, marker: marker.clone(), lines }); + } + } let mut controls = BTreeMap::new(); for (index, region) in f.regions.iter().enumerate() { let rect = region.rect; @@ -147,6 +172,7 @@ pub(super) fn compile_fullscreen( default_action: region.default_action.clone(), hover: outlined(region), outline: outline_texts.remove(&index).filter(|text| !text.is_empty()), + tooltip: tooltips.remove(&index), source: Some(region.source.clone()), }; contract.controls.push(rect); @@ -240,6 +266,37 @@ fn outlined(region: &RegionIr) -> bool { region.hover.unwrap_or(region.action.is_some()) } +/// The tooltip panel of each tooltip of `f`, in order, with its codepoint allocation key, which panels with the same +/// rect share: a vanilla-styled box with cut corners, a purple border, and a dark translucent fill. +pub(super) fn tooltip_panels(f: &LaidOutFullscreen) -> impl Iterator + '_ { + let prefix = fullscreen_key_prefix(&f.name); + f.tooltips.iter().map(move |tooltip| { + let Rect { width, height, .. } = tooltip.panel; + let mut texture = Texture::transparent(width, height); + for y in 0..height { + let shade = |top: u8, bottom: u8| { + let t = f64::from(y) / f64::from(height.max(2) - 1); + (f64::from(top) + (f64::from(bottom) - f64::from(top)) * t).round() as u8 + }; + for x in 0..width { + let rim = x == 0 || y == 0 || x == width - 1 || y == height - 1; + let corner = (x == 0 || x == width - 1) && (y == 0 || y == height - 1); + let border = !rim && (x == 1 || y == 1 || x == width - 2 || y == height - 2); + if corner { + continue; + } + let rgba = + if border { [shade(0x24, 0x18), 0x00, shade(0x5b, 0x33), 0xf0] } else { [0x10, 0x00, 0x10, 0xf0] }; + let at = ((y * width + x) * 4) as usize; + texture.rgba[at..at + 4].copy_from_slice(&rgba); + } + } + let Rect { x, y, .. } = tooltip.panel; + let key = format!("{prefix}/tooltip/{x}_{y}_{width}x{height}"); + (key, Composite { texture, bounds: tooltip.panel, has_content: true }) + }) +} + /// The hover outline of each outlined region of `f`, with the region's index and its codepoint allocation key: a 1px /// white border just inside the region's rect. pub(super) fn outlines(f: &LaidOutFullscreen) -> impl Iterator + '_ { diff --git a/crates/window-core/src/pipeline/glyphs.rs b/crates/window-core/src/pipeline/glyphs.rs index a9fe6b97..f88c84b0 100644 --- a/crates/window-core/src/pipeline/glyphs.rs +++ b/crates/window-core/src/pipeline/glyphs.rs @@ -78,7 +78,8 @@ fn layers( /// Allocate codepoints across the whole build: one key per static, case, or art layer composite /// with content, one per glyph of a bar's fill, one per runtime sprite when any window uses sprites, the hover -/// outline keys `hover`, and the built-in full-screen glyphs and control outlines when there are full-screen windows. +/// outline keys `hover`, and the built-in full-screen glyphs and control outlines and tooltip panels when there are +/// full-screen windows. pub(super) fn allocate_codepoints( windows: &[&LaidOutWindow], huds: &[&LaidOutHud], @@ -99,6 +100,9 @@ pub(super) fn allocate_codepoints( for (_, key, outline) in super::fullscreen::outlines(f) { composite_keys(&mut keys, key, &outline, true); } + for (key, panel) in super::fullscreen::tooltip_panels(f) { + composite_keys(&mut keys, key, &panel, true); + } } if !fullscreens.is_empty() { keys.extend([FULLSCREEN_CURSOR_KEY.to_string(), FULLSCREEN_BACKDROP_KEY.to_string()]); diff --git a/crates/window-core/src/pipeline/tests/fullscreens.rs b/crates/window-core/src/pipeline/tests/fullscreens.rs index 38b7dd1f..44235e53 100644 --- a/crates/window-core/src/pipeline/tests/fullscreens.rs +++ b/crates/window-core/src/pipeline/tests/fullscreens.rs @@ -80,7 +80,7 @@ fn compiles_layers_controls_and_the_contract() { assert_eq!(color(&status["low"], 0), (3, "255, 77, 77")); assert_eq!(color(menu.backdrop_marker.as_deref().unwrap(), 2), (0, "16, 16, 16")); assert_eq!(color(&canvas.cursor.marker, 1), (255, "255, 255, 255")); - assert_eq!(color(&canvas.overlay_marker, 0), (254, "255, 255, 255")); + assert_eq!(color(&canvas.overlay_marker, 0), (fullscreen::OVERLAY_LAYER.into(), "255, 255, 255")); let play = &menu.controls["play"]; assert_eq!((play.index, play.x, play.y, play.width, play.height), (0, 0, 0, 40, 20)); @@ -130,15 +130,73 @@ fn full_screen_windows_emit_their_shaders_and_need_the_define_variant_profile() } #[test] -fn rejects_inventory_elements_and_hitbox_items() { +fn rejects_items_runtime_sprites_and_hitbox_models() { + let reject = |child: serde_json::Value| compile(json!({ "name": "menu", "children": [child] })).unwrap_err(); let item = json!({ "type": "item", "handle": { "kind": "items", "id": "loot" }, "slots": [0] }); - let error = compile(json!({ "name": "menu", "children": [item] })).unwrap_err().to_string(); - assert!(error.contains("fullscreen `menu` has no inventory slots for inventory element `loot`"), "{error}"); + let error = reject(item).to_string(); + assert!(error.contains("cannot contain inventory element `loot`: real items need inventory slots"), "{error}"); - let region = json!({ "type": "region", "on_click": { "kind": "action", "id": "play" }, "width": 8, "height": 8, - "tooltip": "Play" }); - let error = compile(json!({ "name": "menu", "children": [region] })).unwrap_err().to_string(); - assert!(error.contains("sets `tooltip` or `item_model`, which need inventory slots"), "{error}"); + let sprite = json!({ "type": "sprite_slot", "handle": { "kind": "sprite", "id": "icon" }, "width": 16, + "height": 16 }); + let error = reject(sprite).to_string(); + assert!( + error.contains("cannot contain runtime sprite slot `icon`: full-screen windows draw no runtime"), + "{error}" + ); + + let region = json!({ "type": "region", "width": 8, "height": 8, "item_model": "window:button" }); + let error = reject(region).to_string(); + assert!(error.contains("sets `item_model`, which draws the hitbox item of inventory slots"), "{error}"); + + let region = json!({ "type": "region", "width": 8, "height": 8, "tooltip": "Hot" }); + let error = reject(region).to_string(); + assert!(error.contains("full-screen tooltips draw plain text, without MiniMessage tags"), "{error}"); +} + +#[test] +fn lays_tooltips_out_beside_their_controls_above_every_window_layer() { + let region = |id: &str, x: u32, y: u32, width: u32, tooltip: serde_json::Value| { + let region = json!({ "type": "region", "on_click": { "kind": "action", "id": id }, "tooltip": tooltip }); + json!({ "type": "flex", "x": x, "y": y, "style": { "width": width, "height": 20 }, "children": [region] }) + }; + let out = compile(json!({ "name": "menu", "children": [ + region("top", 0, 10, 40, json!("Top")), + region("bottom", 470, 240, 10, json!({ "title": "Bottom", "lines": ["One", "Two"] })), + region("plain", 200, 100, 40, json!(null)), + ] })) + .unwrap(); + let menu = &out.manifest.fullscreens["menu"]; + assert_eq!(menu.controls["plain"].tooltip, None); + let table = color_table(include(&out)); + + // Centered below its control: a 4px inset around one 8px line. + let top = menu.controls["top"].tooltip.as_ref().unwrap(); + assert_eq!(decode(&top.marker, 0).0, u32::from(fullscreen::TOOLTIP_LAYER)); + let [title] = top.lines.as_slice() else { panic!("{:?}", top.lines) }; + assert_eq!((title.x, title.y, title.text.as_deref()), ((40 - (title.width as i32 + 8)) / 2 + 4, 36, Some("Top"))); + let (layer, color) = decode(title.shader_marker.as_deref().unwrap(), 0); + assert_eq!((layer, table[color].as_str()), (u32::from(fullscreen::TOOLTIP_TEXT_LAYER), "255, 255, 255")); + + // Above its control where the canvas ends below it, and kept on the canvas; the title is 2px further from its + // lines than they are apart. + let bottom = menu.controls["bottom"].tooltip.as_ref().unwrap(); + let rows: Vec<(i32, i32)> = bottom.lines.iter().map(|line| (line.x, line.y)).collect(); + let panel_height = 8 + 2 + 2 * 10 + 8; + let top_y = 240 - 2 - panel_height + 4; + let left = 480 - (bottom.lines.iter().map(|line| line.width).max().unwrap() as i32 + 8) + 4; + assert_eq!(rows, [(left, top_y), (left, top_y + 12), (left, top_y + 22)]); + let gray = decode(bottom.lines[1].shader_marker.as_deref().unwrap(), 0).1; + assert_eq!(table[gray], "170, 170, 170"); + + let file = out + .files + .iter() + .find(|file| file.path.ends_with(&format!("menu_tooltip_{}_32_{}x16_0_0.png", title.x - 4, title.width + 8))) + .unwrap(); + let panel = Texture::decode_png(file.contents.as_bytes()).unwrap(); + assert_eq!((panel.width, panel.height), (title.width + 8, 16)); + assert_eq!(panel.get(0, 0)[3], 0, "the corners are cut"); + assert_eq!(panel.get(4, 4), [0x10, 0x00, 0x10, 0xf0]); } #[test] diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index 721e84c7..802b61d5 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -610,6 +610,26 @@ HUD is not limited by where the actionbar sits: with the default `sourceBottom` bottom of the GUI (59 plus Minecraft's 13 px actionbar inset), and rows from 72 on are baked below the screen edge, which the client still draws once the shader has moved them. +### Full-screen windows + +`` lays its children out on a 480x270 canvas drawn over the whole screen with no screen +open; the player aims a cursor by turning. It takes the same elements as a window, except those that need a container: + +- **Supported**: art, static and bound text, switches and conditions, tints, bars, regions and the industrial controls + built on them (with hover outlines), `toggle` and `selection` state, and indexed handles. A ``'s cells are + separate controls: each is hit-tested and outlined on its own, and a click calls the indexed action's handler with the + cell's index, such as `onPickEntry(index, click)`. Page a list through the cells with `FullscreenView.list(...)`, + which works like `WindowView.list(...)`. +- **Tooltips**: the server sends a control's tooltip while the cursor is over it, as a vanilla-styled panel centered + below the control, or above it where the canvas ends first. The title is white and the lines gray, in plain text: a + MiniMessage tag is a build error. Each line is a [text row](#what-things-cost), and panels with the same size and + place share a texture. +- **Build errors**: real items (``, ``, a ``) and `
`s need inventory slots, + so they stay in container windows; runtime sprites (``; switch between images instead), ``, a + region's `itemModel`, and text `shadow` are also rejected. +- `span` and `at` count grid tracks, which are slots only in a section. A repeater's `cell` is 18px per slot anywhere; + give other industrial controls a size or a grid to fill on a full-screen window. + ## Industrial `plugin:window/theme/industrial` is Window's default look as plain components built from the [primitives](#primitives) diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt index 113e269d..578960ee 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt @@ -1,6 +1,7 @@ package com.chunkzero.window import com.chunkzero.window.internal.HudComposer +import com.chunkzero.window.internal.HudLayer import com.chunkzero.window.internal.baseStyle import com.chunkzero.window.internal.requireColor import com.chunkzero.window.manifest.FullscreenCanvasEntry @@ -70,6 +71,21 @@ public class FullscreenDefinition( .mapNotNull { (key, control) -> control.outline?.let { key to run(it, canvas.overlayMarker) } } .toMap() + /** The tooltip runs of each control with one, by control key: its panel, then its lines. */ + internal val tooltips: Map = + entry.controls + .mapNotNull { (key, control) -> + control.tooltip?.let { tooltip -> + val id = "fullscreen/$name/tooltip/$key" + val lines = + tooltip.lines.mapNotNull { line -> + composer.renderSlot(id, line, Component.text(line.text.orEmpty()))?.let(HudLayer::Slot) + } + val panel = HudLayer.Art(id, tooltip.panel, null, requireColor(tooltip.marker)) + key to composer.composeLayers(listOf(panel) + lines) + } + }.toMap() + private fun run( text: String, marker: String, diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index 1c28e8b4..dac0f2ae 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -24,7 +24,7 @@ public sealed class FullscreenSession { /** * A full-screen window shown through [host]: maps the player's rotation to the canvas cursor, hit-tests clicks, draws - * the hovered control's outline, and re-sends the runs when reactive state or the hovered control changes. + * the hovered control's outline and tooltip, and re-sends the runs when reactive state or the hovered control changes. * * A player turned more than a degree past a canvas edge is turned back to it, at most once per round trip: rotations * the client sent before applying a correction still carry the overshoot. @@ -156,12 +156,18 @@ internal class FullscreenWindowSession( .firstOrNull { (key, control) -> layers.switches.regionActive(key) && mapping.hits(control, point) } ?.key - /** The backdrop, the window's layers, the hovered control's outline, and the cursor. */ + /** The backdrop, the window's layers, the hovered control's tooltip and outline, and the cursor. */ private fun content(): Component = Component .text() .append( - listOfNotNull(definition.backdrop, layout, hovered?.let(definition.outlines::get), definition.cursor), + listOfNotNull( + definition.backdrop, + layout, + hovered?.let(definition.tooltips::get), + hovered?.let(definition.outlines::get), + definition.cursor, + ), ).build() private fun show() { diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index 271bfb10..7e75a307 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -47,6 +47,22 @@ public abstract class FullscreenView( */ protected fun state(initial: T): ReadWriteProperty = LazyState(initial) { reactivity } + /** + * Creates a [WindowList] showing [source] through [cells] cells, keyed by [key], as [WindowView.list] does: read + * [WindowList.at] in a repeater's indexed renders, and back previous and next buttons with [WindowList.canPrevious] + * and [WindowList.canNext]. [WindowList.items] adapts a list to item collections, which full-screen windows do not + * have. + * + * @throws IllegalArgumentException if [cells] or [step] is not positive. + */ + protected fun list( + cells: Int, + key: (T) -> K, + step: Int = cells, + select: WindowList.Select = WindowList.Select.FIRST, + source: () -> List, + ): WindowList = WindowList(cells, key, step, select, source) { reactivity } + /** Declares the slot renders and button handlers for this view. */ protected abstract fun FullscreenScope.bind() diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowList.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowList.kt index d6c62b26..1e506a12 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowList.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/WindowList.kt @@ -5,7 +5,7 @@ import com.chunkzero.window.internal.LazyState import com.chunkzero.window.internal.Reactivity /** - * A reactive list shown through a fixed number of cells, created with [WindowView.list]. + * A reactive list shown through a fixed number of cells, created with [WindowView.list] or [FullscreenView.list]. * * The offset of the first visible item and the selected item's key are view state: reading them * while rendering re-renders when they change. The offset is clamped to the current list when read, 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 91c3a94e..23fa9a62 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 @@ -152,8 +152,19 @@ internal class HudComposer( hudName: String, layers: List, ): ComposedRender { - val base = if (shaderHud) 0 else originX val traces = arrayListOf(staticTrace("hud/$hudName/static", hud.static, originX, staticColor)) + return ComposedRender(staticComponent.appendAll(composeParts(layers, traces)), traces) + } + + /** [layers] composed as [compose] composes them, without the static segment. */ + fun composeLayers(layers: List): Component = + Component.empty().appendAll(composeParts(layers, ArrayList())) + + private fun composeParts( + layers: List, + traces: MutableList, + ): List { + val base = if (shaderHud) 0 else originX val parts = ArrayList() var cursor = base for (layer in layers) { @@ -180,7 +191,7 @@ internal class HudComposer( } } parts.addSpacer(base - cursor) - return ComposedRender(staticComponent.appendAll(parts), traces) + return parts } /** A static layer sent in [color], starting at the HUD's left edge, whose cursor ends at [end]. */ diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt index d74f6afc..67b4802b 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt @@ -79,10 +79,23 @@ public data class ControlEntry( * is over the control; present when [hover] is set. */ val outline: String? = null, + /** The tooltip sent while the cursor is over the control. */ + val tooltip: ControlTooltipEntry? = null, /** The authored element this control comes from, for diagnostics. */ val source: String? = null, ) +/** A full-screen control's tooltip: a panel the compiler placed beside the control, and its plain-text lines. */ +@Serializable +public data class ControlTooltipEntry( + /** The panel, net-zero from the line origin. */ + val panel: String, + /** The marker color [panel] is sent in. */ + val marker: String, + /** The title, then each line: static labels, each sent in its [SlotEntry.shaderMarker]. */ + val lines: List, +) + /** * The full-screen canvas contract, shared with the generated `window_fullscreen.glsl` include. * diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt index 3c05d784..814b81d3 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenManifests.kt @@ -2,6 +2,7 @@ package com.chunkzero.window import com.chunkzero.window.manifest.Align import com.chunkzero.window.manifest.ControlEntry +import com.chunkzero.window.manifest.ControlTooltipEntry import com.chunkzero.window.manifest.CursorEntry import com.chunkzero.window.manifest.FullscreenCanvasEntry import com.chunkzero.window.manifest.FullscreenEntry @@ -13,11 +14,16 @@ import com.chunkzero.window.manifest.SwitchCaseEntry import com.chunkzero.window.manifest.SwitchEntry import com.chunkzero.window.manifest.WindowManifest -/** A full-screen menu: a backdrop, a bound count, a `play` button with an outline, a `quit` button, and a switch. */ +/** + * A full-screen menu: a backdrop, a bound count, a `play` button with an outline, a `quit` button, a switch, and a + * two-cell repeater paged by `next`, whose second cell has a tooltip. + */ object FullscreenManifests { const val STATIC_MARKER = "#a00400" const val COUNT_MARKER = "#a00801" const val OVERLAY_MARKER = "#a3f800" + const val TOOLTIP_MARKER = "#a3f000" + const val TOOLTIP_TEXT_MARKER = "#a3f401" val canvas = FullscreenCanvasEntry( @@ -49,12 +55,20 @@ object FullscreenManifests { surface = FullscreenSurfaceEntry("fullscreen", mode, listOf(480, 270)), static = "STATIC", staticMarker = STATIC_MARKER, - slots = mapOf("count" to TestManifests.slot(0, 40, Align.LEFT)), + slots = + mapOf( + "count" to TestManifests.slot(0, 40, Align.LEFT), + "cell[0]" to TestManifests.slot(100, 40, Align.LEFT, y = 190), + "cell[1]" to TestManifests.slot(150, 40, Align.LEFT, y = 190), + ), controls = mapOf( "play" to control(0, 100, action = "play", outline = "OUTLINE"), "quit" to control(1, 130, action = "window:close", default = "window:close"), "secret" to control(2, 160, action = "secret"), + "pick[0]" to control(3, 190, action = "pick[0]"), + "pick[1]" to control(4, 190, action = "pick[1]", x = 150, tooltip = tooltip), + "next" to control(5, 220, action = "next"), ), switches = mapOf( @@ -66,22 +80,57 @@ object FullscreenManifests { ), ), ), - layers = listOf(LayerEntry(LayerKind.SLOT, "count"), LayerEntry(LayerKind.SWITCH, "mode")), - markers = mapOf("count" to COUNT_MARKER, "mode" to "#a00c00"), + layers = + listOf( + LayerEntry(LayerKind.SLOT, "count"), + LayerEntry(LayerKind.SWITCH, "mode"), + LayerEntry(LayerKind.SLOT, "cell[0]"), + LayerEntry(LayerKind.SLOT, "cell[1]"), + ), + markers = + mapOf( + "count" to COUNT_MARKER, + "mode" to "#a00c00", + "cell[0]" to "#a01000", + "cell[1]" to "#a01400", + ), backdropMarker = "#a80000", ), ), fullscreenCanvas = canvas, ) - /** A 40x20 control at x 100 and [y]. */ + private val tooltip = + ControlTooltipEntry( + panel = "PANEL", + marker = TOOLTIP_MARKER, + lines = + listOf( + TestManifests.slot(150, 30, Align.LEFT, shaderMarker = TOOLTIP_TEXT_MARKER, text = "Tip", y = 214), + ), + ) + + /** A 40x20 control at [x] and [y]. */ private fun control( index: Int, y: Int, action: String, default: String? = null, outline: String? = null, - ) = ControlEntry(index, 100, y, 40, 20, action, default, hover = outline != null, outline = outline) + x: Int = 100, + tooltip: ControlTooltipEntry? = null, + ) = ControlEntry( + index, + x, + y, + 40, + 20, + action, + default, + hover = outline != null, + outline = outline, + tooltip = tooltip, + ) /** The yaw and pitch that put the cursor on the centre of canvas pixel ([x], [y]). */ fun rotation( diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index 8d64a48c..92b39af6 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -7,7 +7,10 @@ import com.chunkzero.window.internal.Turn import com.chunkzero.window.manifest.FullscreenMode import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain @@ -100,6 +103,41 @@ class FullscreenSessionTest : TextColor.fromHexString(FullscreenManifests.OVERLAY_MARKER) } + "repeater cells are hit and hovered per cell, and the hovered cell's tooltip is sent above the layers" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + host.look(110, 200) + host.listener.onClick() + host.look(160, 200) + host.listener.onClick() + host.look(200, 250) + + menu.picked shouldContainExactly listOf("a", "b") + host.shown.map { "Tip" in text(it) } shouldContainExactly listOf(false, true, false) + val runs = runs(host.shown[1]) + runs.getValue("PANEL").color shouldBe TextColor.fromHexString(FullscreenManifests.TOOLTIP_MARKER) + runs.getValue("Tip").color shouldBe TextColor.fromHexString(FullscreenManifests.TOOLTIP_TEXT_MARKER) + val shown = text(host.shown[1]) + shown.indexOf("PANEL") shouldBeGreaterThan shown.indexOf("N0") + shown.indexOf("CURSOR") shouldBeGreaterThan shown.indexOf("Tip") + } + + "a list pages its items through the repeater's cells" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + host.look(110, 230) + host.listener.onClick() + host.scheduler.tick() + host.look(110, 200) + host.listener.onClick() + + runs(host.shown.last()).keys shouldContain "c" + runs(host.shown.last()).keys shouldNotContain "a" + menu.picked shouldContainExactly listOf("c") + } + "state changes re-send the runs on the next tick" { val host = FakeFullscreenHost() Menu(host).open() @@ -146,6 +184,8 @@ private class Menu( var plays = 0 var secrets = 0 var closes = 0 + val picked = mutableListOf() + private val names = list(2, { it: String -> it }) { listOf("a", "b", "c") } override fun FullscreenScope.bind() { slot("count") { Component.text("N$count") } @@ -155,6 +195,11 @@ private class Menu( count++ } button("secret") { secrets++ } + for (cell in 0 until 2) { + slot("cell[$cell]") { Component.text(names.at(cell).orEmpty()) } + button("pick[$cell]") { names.selectAt(cell)?.let(picked::add) } + } + button("next") { names.next() } } override fun onClose() { diff --git a/plugin/src/theme/industrial/slots.tsx b/plugin/src/theme/industrial/slots.tsx index afe8847c..d76d10aa 100644 --- a/plugin/src/theme/industrial/slots.tsx +++ b/plugin/src/theme/industrial/slots.tsx @@ -49,7 +49,7 @@ export function Slots(props: SlotsProps): Element { } export interface RepeaterProps extends ItemStyle, DebugProps { - /** Size of one cell in slots. */ + /** Size of one cell in slots: 18px each, also outside a section, as in a full-screen window. */ cell: [number, number]; columns: number; rows: number; @@ -127,7 +127,15 @@ export function Repeater(props: RepeaterProps): Element { ); }); return ( - + {cells} ) as Element; diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 8fd0acf6..af5bd89d 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -2,11 +2,15 @@ 2ba7e616fbdb0334aab7a729aabc0531f4d9daf73ad24aa249971dc6ca2972d8 dist/assets/minecraft/shaders/core/position_tex_color.vsh 950440cf29257b3e036e9aeb2aa7e8e8383e0855820f46b8cda64f77bdb029b6 dist/assets/minecraft/shaders/core/text.fsh 10955aaeef507e673f474143791a4a31ed838d84e6d3ed54231e37f02e425fb6 dist/assets/minecraft/shaders/core/text.vsh -6d0fd0ae349322d6b34846bcdba9fce9fc00c1cb05192db91a6d769dc2da9601 dist/assets/minecraft/shaders/include/window_fullscreen.glsl +d8c8dc63512f18a6f85b86ce57d473cc2b2b76453ba1c0fd3ba97b47bbb5b26f dist/assets/minecraft/shaders/include/window_fullscreen.glsl 8f0a28c175d2c93ff86aa1377f34c9e8fbc76e922ca34ae2f6db19dd557d285e dist/assets/minecraft/textures/gui/sprites/hud/crosshair.png -ce82cef26d6b723cfe21ff646da7e701caf7e4759b9c8984725a9b50002396b4 dist/assets/window/font/ui.json +36544f87434a58ca80349fb16fbbe42b24133f26bb83bab37e684d1cb7a10c73 dist/assets/window/font/ui.json +8442db86bb9acb34f4a3af439905fd115deba750a42d5864e10d10b2973f3afa dist/assets/window/font/y111.json 8bd2e56c97397bda94a6340e11aa2381582f597ee5df895815ab34ae8e23a616 dist/assets/window/font/y119.json -e367eb8a3ebbb32324c3b028964cd84290aeaafe2b9eabd8006ac7c780fb23ee dist/assets/window/font/y141.json +5907c4a6b31c25b560140b3ff16a7a220bc693a42eb76eaf8bfd4eac43c52d54 dist/assets/window/font/y123.json +b1c6c96c4eac77d2757fa8c7c7fb2820c55be81a5e9387d8ad4b85042601e5a9 dist/assets/window/font/y146.json +96060c3db0888967db3490bad6be72b4a994eb584637844de3dd11eaf08f6cb8 dist/assets/window/font/y164.json +cd6e87859e19d3557432a4814bcd09f99e956e9ff28b7f9cbb5363b73b0f5db9 dist/assets/window/font/y165.json 7331c72a4138ce09f0c949f1fa86362934cc0530bfea61fecf3cb1e53a1ef27e dist/assets/window/font/y53.json 456099b7b4af40d12c474af64c5be37c08f6e03a5dd3e3329b8c8ec10af1765b dist/assets/window/font/y67.json 395d18735d6aadbd1df33f173fe8119227b597556687aedc293f5a86cc54bbda dist/assets/window/font/y8.json @@ -16,18 +20,25 @@ c958c1c1e1a10be6b5276aa780f0e6c3cdd86f5326467240765d8059877a17f3 dist/assets/wi dc49481287414f39e74f7ef95a92814997f89f1b09b453bc34416f30787dd5b4 dist/assets/window/models/gui/hitbox.json 74ebd720cee9a06ebbd6008d1ff58ce16c5dc062cbb69869741e1599a289497f dist/assets/window/textures/font/fullscreen/builtin/backdrop.png 24eb44ad123dcfce9f4dbdc3b5588aea9fd6285b7b0117bc789656c0907e35ce dist/assets/window/textures/font/fullscreen/builtin/cursor.png -c685026fc5f903fcd19d771da6be0e55347aafdef87422ad63c16a5c05a45910 dist/assets/window/textures/font/fullscreen/window/menu_0_0.png +b344e2481c7fc30eb9d858cd74349b1a1875bd5429f7cb52fd1c55769e4bda54 dist/assets/window/textures/font/fullscreen/window/menu_0_0.png a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/window/textures/font/fullscreen/window/menu_hover_0_0_0.png +84a6f3557c750e1a5bdac024ca9b9f6ca15454cafd5317cc67c5719510316d8a dist/assets/window/textures/font/fullscreen/window/menu_hover_2_0_0.png +84a6f3557c750e1a5bdac024ca9b9f6ca15454cafd5317cc67c5719510316d8a dist/assets/window/textures/font/fullscreen/window/menu_hover_3_0_0.png +84a6f3557c750e1a5bdac024ca9b9f6ca15454cafd5317cc67c5719510316d8a dist/assets/window/textures/font/fullscreen/window/menu_hover_4_0_0.png +922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_184_160_41x16_0_0.png +62edcf5c47cee1f8503fb7d7978d1f22b927b666e926d55b4462e9307e85334b dist/assets/window/textures/font/fullscreen/window/menu_tooltip_199_107_83x28_0_0.png +922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png +922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -9481e534191064dd3f83ebb97a6ba3ca0b967c442808258b0817defd0ef13d61 dist/assets/window/window/debug.json +7ce3b2de64e483d401f995e197ae7c6c2c8a412daca1029eaa11fe127f1dc621 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta dfda9d83b4d15cc55829592524be316592ec273f851c54a0991791f026dee0ae kotlin-out/LookoutFullscreen.kt -3983b91d0826ee55ddd9b07526240d8a32c42e16023fec957917bbdb5759c367 kotlin-out/MenuFullscreen.kt +2325f51e0f83d45dae5a93ce669ae7a50d17c32f87df8caad653c139a7388486 kotlin-out/MenuFullscreen.kt f30b961fdca503fa68f87b140e31c8a42b48ad830a84c029fc7a7435760bfff9 kotlin-out/WindowDefinitions.kt 2c40c9884ffe5ae1a29707d1195e2a15ad469816e166981e0be0b73b1e1a3696 kotlin-out/WindowEntries.kt -87e25adf61686af7eaad080edd2f923102aab006421bef2c7663dfd8e725e74e kotlin-out/WindowFonts.kt +803c1f2387cf2cca16681dad3c4b38b360eefcda17be343491aca1ec552cc0ce kotlin-out/WindowFonts.kt bd87985f4223a45529a54d7453105bf2525188d337c8910cd4935344e0cec3bd kotlin-out/WindowFullscreenDefinitions.kt -c67367fa1a42351cc33700e39c0a22cc2ec93a5d4521f47a9929716f09467bd7 kotlin-out/WindowFullscreenEntries.kt +725e513c1ad41d6fe991428193c16e2c0666fb91261c2ee13c0e35f3be1e7992 kotlin-out/WindowFullscreenEntries.kt 7e12c0496b9c2f1e69bebd517cf396d3d178322561535b9b4521b9e5e2f4c644 kotlin-out/WindowHudDefinitions.kt f14312feddb64e8e65d2916d8e066c8323d35628c9da4e722e2f12f057d516e3 kotlin-out/WindowHudEntries.kt 9a1b53ef8c7c748afb02db43707298badd154a58dfc0ba7abada76b7a10d212f kotlin-out/WindowPackData.kt diff --git a/plugin/test/golden/fullscreen/ts/window/index.tsx b/plugin/test/golden/fullscreen/ts/window/index.tsx index 4917ed86..6d3b46f5 100644 --- a/plugin/test/golden/fullscreen/ts/window/index.tsx +++ b/plugin/test/golden/fullscreen/ts/window/index.tsx @@ -1,6 +1,7 @@ import { Box, Case, Fullscreen, Region, Switch, Text, defineWindows, shape } from "plugin:window/ui"; import { action, builtin, flag, text, tint } from "plugin:window/bind"; import * as raw from "plugin:window/raw"; +import { Repeater } from "plugin:window/theme/industrial"; const panel = shape({ kind: "panel", @@ -19,21 +20,27 @@ const button = shape({ inset_depth: 0, }); const tone = tint("tone", { ok: "#3ddc84", low: "#ff4d4d" }); +const pick = action("pick", { shape: [3] }); +const slotName = text("slot_name", { shape: [3] }); -// An unlocked full-screen menu over a backdrop, with buttons, bound and tinted text, and a switch; and a locked view. +// An unlocked full-screen menu over a backdrop, with buttons, bound and tinted text, a switch, and a repeater whose +// cells and play button have tooltips; and a locked view. const menu = ( Main menu - + Play Quit + `Save ${i + 1}`}> + {(i) => } + Ready From 46fd8e9732da87cd4eba52aead5614f61ff979fe Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:42:47 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20keep=20container=20repeater=20sizing?= =?UTF-8?q?,=20split=20tooltip=20newlines,=20and=20reject=20=C2=A7=20in=20?= =?UTF-8?q?tooltips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - opts into 18px-per-slot sizing; the default again leaves sizing to the section area, so container click routing is unchanged. - Embedded newlines in full-screen tooltips start separately positioned lines. - Tooltips reject § formatting codes alongside MiniMessage tags. - Newlines in bound full-screen slot text are sent as spaces. --- crates/window-core/src/layout/tooltips.rs | 14 ++++++++--- .../src/pipeline/tests/fullscreens.rs | 18 +++++++++++++ docs/AUTHORING.md | 4 +-- .../com/chunkzero/window/FullscreenSession.kt | 2 +- .../chunkzero/window/FullscreenSessionTest.kt | 14 ++++++++++- plugin/src/theme/industrial/slots.tsx | 25 ++++++++++++++++--- .../golden/fullscreen/ts/window/index.tsx | 2 +- plugin/test/industrial.test.ts | 7 ++++++ 8 files changed, 74 insertions(+), 12 deletions(-) diff --git a/crates/window-core/src/layout/tooltips.rs b/crates/window-core/src/layout/tooltips.rs index 55eba267..4fede5e0 100644 --- a/crates/window-core/src/layout/tooltips.rs +++ b/crates/window-core/src/layout/tooltips.rs @@ -27,14 +27,21 @@ pub(super) fn tooltip( region: &RegionIr, tooltip: &Tooltip, ) -> Result { - let texts: Vec<&str> = - std::iter::once(tooltip.title.as_str()).chain(tooltip.lines.iter().map(String::as_str)).collect(); - if texts.iter().any(|text| text.contains('<')) { + let authored = std::iter::once(tooltip.title.as_str()).chain(tooltip.lines.iter().map(String::as_str)); + if authored.clone().any(|text| text.contains('<')) { return Err(target.layout_err(format!( "{} has a tooltip with `<`: full-screen tooltips draw plain text, without MiniMessage tags", region.source ))); } + if authored.clone().any(|text| text.contains('§')) { + return Err(target.layout_err(format!( + "{} has a tooltip with `§`: full-screen tooltips draw plain text, without formatting codes", + region.source + ))); + } + // An embedded newline starts another tooltip line, as in vanilla; the display itself never sees one. + let texts: Vec<&str> = authored.flat_map(|text| text.split('\n')).collect(); let measure = |text: &str| vanilla::visible_width(text, false, vanilla::advance, vanilla::glyph_width); let text_width = texts.iter().map(|text| measure(text)).max().unwrap_or(0); let extra = texts.len() as u32 - 1; @@ -60,6 +67,7 @@ pub(super) fn tooltip( let lines = texts .iter() .enumerate() + .filter(|(_, text)| !text.is_empty()) .map(|(line, text)| { let top = if line == 0 { 0 } else { TITLE_GAP + line as u32 * LINE_PITCH }; let origin = Point::new(panel.x + INSET as i32, panel.y + (INSET + top) as i32); diff --git a/crates/window-core/src/pipeline/tests/fullscreens.rs b/crates/window-core/src/pipeline/tests/fullscreens.rs index 44235e53..b443f2d0 100644 --- a/crates/window-core/src/pipeline/tests/fullscreens.rs +++ b/crates/window-core/src/pipeline/tests/fullscreens.rs @@ -151,6 +151,24 @@ fn rejects_items_runtime_sprites_and_hitbox_models() { let region = json!({ "type": "region", "width": 8, "height": 8, "tooltip": "Hot" }); let error = reject(region).to_string(); assert!(error.contains("full-screen tooltips draw plain text, without MiniMessage tags"), "{error}"); + + let region = + json!({ "type": "region", "width": 8, "height": 8, "tooltip": { "title": "Hot", "lines": ["§cDanger"] } }); + let error = reject(region).to_string(); + assert!(error.contains("full-screen tooltips draw plain text, without formatting codes"), "{error}"); +} + +#[test] +fn splits_embedded_newlines_into_tooltip_lines() { + let region = json!({ "type": "flex", "x": 100, "y": 50, "style": { "width": 40, "height": 20 }, "children": [ + { "type": "region", "on_click": { "kind": "action", "id": "play" }, + "tooltip": { "title": "First\nSecond", "lines": ["Third\n\nFifth"] } } + ] }); + let out = compile(json!({ "name": "menu", "children": [region] })).unwrap(); + let tooltip = out.manifest.fullscreens["menu"].controls["play"].tooltip.as_ref().unwrap(); + let rows: Vec<(&str, i32)> = tooltip.lines.iter().map(|line| (line.text.as_deref().unwrap(), line.y)).collect(); + assert_eq!(rows, [("First", 76), ("Second", 88), ("Third", 98), ("Fifth", 118)]); + assert!(tooltip.lines.iter().all(|line| !line.text.as_deref().unwrap().contains('\n'))); } #[test] diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index 802b61d5..a57c91d4 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -627,8 +627,8 @@ open; the player aims a cursor by turning. It takes the same elements as a windo - **Build errors**: real items (``, ``, a ``) and `
`s need inventory slots, so they stay in container windows; runtime sprites (``; switch between images instead), ``, a region's `itemModel`, and text `shadow` are also rejected. -- `span` and `at` count grid tracks, which are slots only in a section. A repeater's `cell` is 18px per slot anywhere; - give other industrial controls a size or a grid to fill on a full-screen window. +- `span` and `at` count grid tracks, which are slots only in a section. A `` sizes its `cell`s at 18px + per slot; give other industrial controls a size or a grid to fill on a full-screen window. ## Industrial diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index dac0f2ae..24497cca 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -193,7 +193,7 @@ internal class FullscreenWindowSession( render: () -> Component, ) { requireBindable(name) - layers.slots.bind(name, render) + layers.slots.bind(name) { render().replaceText { config -> config.matchLiteral("\n").replacement(" ") } } } override fun switch( diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index 92b39af6..6b2a409f 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -168,6 +168,17 @@ class FullscreenSessionTest : host.turns shouldContainExactly listOf(Turn(-5f, 0f), Turn(-5f, 0f)) } + "a newline in bound slot text is sent as a space" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + menu.suffix = "a\nb" + host.scheduler.tick() + + text(host.shown.last()) shouldContain "N0a b" + text(host.shown.last()) shouldNotContain "\n" + } + "horse mode is refused" { val host = FakeFullscreenHost() shouldThrow { Menu(host, FullscreenMode.HORSE).open() } @@ -180,6 +191,7 @@ private class Menu( mode: FullscreenMode = FullscreenMode.UNLOCKED, ) : FullscreenView(FullscreenDefinition(FullscreenManifests.manifest(mode), "menu"), host) { var count by state(0) + var suffix by state("") var mode by state("off") var plays = 0 var secrets = 0 @@ -188,7 +200,7 @@ private class Menu( private val names = list(2, { it: String -> it }) { listOf("a", "b", "c") } override fun FullscreenScope.bind() { - slot("count") { Component.text("N$count") } + slot("count") { Component.text("N$count$suffix") } switch("mode") { mode } button("play") { plays++ diff --git a/plugin/src/theme/industrial/slots.tsx b/plugin/src/theme/industrial/slots.tsx index d76d10aa..c37590ad 100644 --- a/plugin/src/theme/industrial/slots.tsx +++ b/plugin/src/theme/industrial/slots.tsx @@ -49,10 +49,12 @@ export function Slots(props: SlotsProps): Element { } export interface RepeaterProps extends ItemStyle, DebugProps { - /** Size of one cell in slots: 18px each, also outside a section, as in a full-screen window. */ + /** Size of one cell in slots. */ cell: [number, number]; columns: number; rows: number; + /** Sizes the grid at 18px per slot instead of letting its section area or content size it; for full-screen windows. */ + fixed?: boolean; /** An action indexed by cell; a click calls it with the clicked cell's index. */ onClick?: Indexed; tooltip?: string | Tooltip | ((i: number) => string | Tooltip); @@ -75,7 +77,21 @@ const SLOT = 18; /** A grid of `columns` x `rows` cells, each a face over `cell` slots whose content is centered in a column. */ export function Repeater(props: RepeaterProps): Element { - const { cell, columns, rows, onClick, tooltip, hover, item, itemSlot = 1, frame, padding, text, style } = props; + const { + cell, + columns, + rows, + fixed, + onClick, + tooltip, + hover, + item, + itemSlot = 1, + frame, + padding, + text, + style, + } = props; const { children } = props; const [width, height] = cell; if (item !== undefined && (itemSlot < 1 || itemSlot > width * height)) { @@ -85,6 +101,7 @@ export function Repeater(props: RepeaterProps): Element { "cell", "columns", "rows", + "fixed", "onClick", "tooltip", "hover", @@ -129,8 +146,8 @@ export function Repeater(props: RepeaterProps): Element { return ( Quit - `Save ${i + 1}`}> + `Save ${i + 1}`}> {(i) => } diff --git a/plugin/test/industrial.test.ts b/plugin/test/industrial.test.ts index ade3af65..3eac894e 100644 --- a/plugin/test/industrial.test.ts +++ b/plugin/test/industrial.test.ts @@ -108,6 +108,13 @@ test("a repeater cell's region covers the whole cell, including its item slot", } }); +test("a repeater leaves its section area to size it unless fixed", () => { + const grid = (fixed?: boolean) => + ofType(industrial.Repeater({ cell: [2, 1], columns: 3, rows: 2, fixed }), "flex")[0]?.style as Node; + assert.deepEqual([grid().width, grid().height], [undefined, undefined]); + assert.deepEqual([grid(true).width, grid(true).height], [108, 36]); +}); + test("a theme over the rivet colors recolors the rivet", () => { const rivet = (overrides: object) => JSON.stringify(