diff --git a/crates/window-core/src/codegen/definitions.rs b/crates/window-core/src/codegen/definitions.rs index 32afbd5d..ecf42e05 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"), @@ -220,7 +221,7 @@ pub(super) fn generate_fullscreen_entries(manifest: &Manifest, package_name: &st (canvas.horse.is_some(), "HorseEntry"), (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 e5d49a56..976c5078 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 0d1df0e7..399b5409 100644 --- a/crates/window-core/src/fullscreen.rs +++ b/crates/window-core/src/fullscreen.rs @@ -75,9 +75,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..4fede5e0 --- /dev/null +++ b/crates/window-core/src/layout/tooltips.rs @@ -0,0 +1,94 @@ +//! 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 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; + 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() + .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); + 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 e09cf59e..8abbcbe5 100644 --- a/crates/window-core/src/manifest.rs +++ b/crates/window-core/src/manifest.rs @@ -16,7 +16,8 @@ mod fullscreen; mod hud; pub use fullscreen::{ - ControlEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry, HorseEntry, + ControlEntry, ControlTooltipEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry, + HorseEntry, }; pub use hud::{HudEntry, HudShaderEntry, HudSurfaceEntry}; diff --git a/crates/window-core/src/manifest/fullscreen.rs b/crates/window-core/src/manifest/fullscreen.rs index 10b5896a..a0cfd495 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 c50e1ea3..24a19e74 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::{FullscreenMode, 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}; @@ -139,6 +142,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; @@ -152,6 +177,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); @@ -248,6 +274,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 55d046be..d34184bb 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,91 @@ 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}"); + + 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] +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..a57c91d4 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 `` 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 `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 745b3a36..04ae5357 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 @@ -75,6 +76,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 189c3bdf..d30a51d4 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -23,7 +23,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. @@ -176,12 +176,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() { @@ -209,7 +215,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/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index d67f0d37..298f7717 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 a1e15960..589934ac 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 21afb6d2..b0e86c95 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() @@ -130,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" + } + "right clicks and scroll steps reach their handlers, right clicks at the cursor" { val host = FakeFullscreenHost() val menu = Menu(host).also { it.open() } @@ -218,22 +267,30 @@ 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 var closes = 0 + val picked = mutableListOf() val rightClicks = mutableListOf() val scrolls = mutableListOf() val prompts = mutableListOf() + 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++ 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() } rightClick { rightClicks += it } scroll { scrolls += it } } diff --git a/plugin/src/theme/industrial/slots.tsx b/plugin/src/theme/industrial/slots.tsx index afe8847c..c37590ad 100644 --- a/plugin/src/theme/industrial/slots.tsx +++ b/plugin/src/theme/industrial/slots.tsx @@ -53,6 +53,8 @@ export interface RepeaterProps extends ItemStyle, DebugProps { 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", @@ -127,7 +144,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 1f3238c9..26cdc03b 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -3,12 +3,16 @@ b902022fd657ad3b7b7cd44391210c7c6f2d5086bebba022fecbbee2a951df3f dist/assets/mi 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 69beab9c90d9a84fa45fd48c393db16ea354338990a7955935cf8518f6fbcf74 dist/assets/window/equipment/fullscreen/horse_cursor.json -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 @@ -19,18 +23,25 @@ dc49481287414f39e74f7ef95a92814997f89f1b09b453bc34416f30787dd5b4 dist/assets/wi 2976902baae7d6f2839820a34cb11efe68b47aaee46a383565fd116450e5e3ba dist/assets/window/textures/entity/equipment/horse_body/fullscreen/horse_cursor.png 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 -2b8fb0a7ddbb21f3786a96db2404a919709a8c8ba2a1d7b7a8914d16be76061a dist/assets/window/window/debug.json +8a52ccfe685388df207fce1a7ebb4fb8e6256d14a88cb39e3db2abcb095e281f dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt -92d2f1d01598c590df7cd43619bd3b83d47048a6955a714733dabe7a25d64fc4 kotlin-out/MenuFullscreen.kt +c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 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 -59d1e77f9b18731f3133f9a2174db48e1c6968ab61dda50aab2d9b3820c0aefb kotlin-out/WindowFullscreenEntries.kt +7d5e947bf50d0a80f24891d586a3c02648844aa5a4b447c3cfea75f84c481e20 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..c5d58024 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 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(