diff --git a/crates/bitty-lua/src/ui.rs b/crates/bitty-lua/src/ui.rs index 9ef4868cd..7305154cc 100644 --- a/crates/bitty-lua/src/ui.rs +++ b/crates/bitty-lua/src/ui.rs @@ -58,9 +58,11 @@ pub enum UiSlot { Top, /// `bottom` — bottom edge band. Bottom, - /// `left` — left edge band. + /// `left` — left edge band slot (no vertical band host yet; the mount + /// gate fails closed with `E_UI_UNAVAILABLE`). Left, - /// `right` — right edge band. + /// `right` — right edge band slot (no vertical band host yet; the mount + /// gate fails closed with `E_UI_UNAVAILABLE`). Right, /// `tabline` — exclusive-claim tab strip (reserved for panel tabs). Tabline, diff --git a/crates/bitty-runtime/src/plugin_runtime/services.rs b/crates/bitty-runtime/src/plugin_runtime/services.rs index 030bb350e..3f1b35b7c 100644 --- a/crates/bitty-runtime/src/plugin_runtime/services.rs +++ b/crates/bitty-runtime/src/plugin_runtime/services.rs @@ -2835,7 +2835,8 @@ impl HostServices for PluginServices { // CTX-0923: an accepted slot this host does not present fails closed // here, after the capability and claim gates, through the same // placement policy the band routing uses, so nothing is admitted and - // then silently dropped at render time. + // then silently dropped at render time. CTX-1341: this includes + // `left`/`right`, which have no vertical band host yet. if let UiSlotPlacement::Unsupported(reason) = ui_slot_placement(ui_slot) { return Err(unsupported_slot_error(ui_slot, reason)); } @@ -6012,8 +6013,9 @@ mod tests { } } } - // top, bottom, left, right, overlay - services.with_ui_blocks(|blocks| assert_eq!(blocks.len(), 5)); + // top, bottom, overlay; left/right/tabline/terminal fail closed + // (CTX-1341: no vertical band host, so no vertical block is retained). + services.with_ui_blocks(|blocks| assert_eq!(blocks.len(), 3)); } #[test] diff --git a/crates/bitty-runtime/src/runtime.rs b/crates/bitty-runtime/src/runtime.rs index 7e2bd6746..0d28f32c5 100644 --- a/crates/bitty-runtime/src/runtime.rs +++ b/crates/bitty-runtime/src/runtime.rs @@ -394,9 +394,11 @@ pub struct ChromeBands { pub top: Vec, /// Bottom edge bands (index `0` at the window bottom, stacking upward). pub bottom: Vec, - /// Left edge bands (stacked left-to-right, deferred for v0.1). + /// Left edge bands (unused: no vertical band host yet; `left` mounts + /// fail closed at mount time under CTX-1341). pub left: Vec, - /// Right edge bands (stacked right-to-left, deferred for v0.1). + /// Right edge bands (unused: no vertical band host yet; `right` mounts + /// fail closed at mount time under CTX-1341). pub right: Vec, } @@ -405,8 +407,9 @@ pub struct ChromeBands { pub struct BandContent { /// Owning plugin id. pub plugin_id: String, - /// Accepted slot the block was mounted into (`top`, `bottom`, - /// `left`, or `right`; see [`band_slots`]). + /// Accepted slot the block was mounted into (`top` or `bottom`; + /// `left`/`right` fail closed at mount until vertical bands ship; see + /// [`band_slots`]). pub slot: bitty_lua::ui::UiSlot, /// Root UiNode tree. pub root: bitty_lua::ui::UiNode, @@ -633,8 +636,9 @@ pub struct Runtime { /// /// Populated from `PluginRuntime::ui_blocks()` each tick after plugin /// runtime tick, before present. Bands are reserved for plugins that - /// mount `UiNode` trees to top/bottom/left/right slots via - /// `bitty.ui.mount(slot, tree)`. Empty edges reserve no band. The + /// mount `UiNode` trees to top/bottom slots via `bitty.ui.mount(slot, + /// tree)`; left/right mounts fail closed until vertical bands ship. + /// Empty edges reserve no band. The /// container rect is computed from window_cells minus all reserved bands. /// Presentation-only: never grid truth. chrome_bands: ChromeBands, diff --git a/crates/bitty-runtime/src/runtime/band_host.rs b/crates/bitty-runtime/src/runtime/band_host.rs index 77fb20a3f..5ea34f9b9 100644 --- a/crates/bitty-runtime/src/runtime/band_host.rs +++ b/crates/bitty-runtime/src/runtime/band_host.rs @@ -578,8 +578,9 @@ impl Runtime { /// /// Counts visible bands only, clamped by the degradation budget /// ([`Self::plugin_band_budget`]): `0` when the window cannot hold the - /// bands above the content floor, or for a vertical edge (vertical - /// bands are stored, never painted). + /// bands above the content floor, or for a vertical edge (this build has + /// no vertical band host, so mounts to `left`/`right` fail closed and no + /// vertical band can exist). #[must_use] pub fn visible_band_count(&self, edge: BandEdge) -> u16 { let (top, bottom) = self.plugin_band_budget(); diff --git a/crates/bitty-runtime/src/runtime/band_slots.rs b/crates/bitty-runtime/src/runtime/band_slots.rs index a8e072b00..ea6ec4ae9 100644 --- a/crates/bitty-runtime/src/runtime/band_slots.rs +++ b/crates/bitty-runtime/src/runtime/band_slots.rs @@ -14,8 +14,8 @@ //! |--------------|--------------------------------------------------------| //! | `top` | top edge band | //! | `bottom` | bottom edge band | -//! | `left` | left edge band — stored, not painted until vertical bands ship | -//! | `right` | right edge band — stored, not painted until vertical bands ship | +//! | `left` | rejected: no vertical band host in this build | +//! | `right` | rejected: no vertical band host in this build | //! | `tabline` | rejected: reserved for PW-10 panel tabs, not a band surface | //! | `overlay` | Core focusable-overlay host (CTX-0941): retained, not a band surface | //! | `terminal` | rejected: no terminal-attached block host yet | @@ -61,9 +61,9 @@ pub enum BandEdge { Top, /// Bottom edge (full window width). Bottom, - /// Left edge (vertical bands are not painted yet). + /// Left edge (no vertical band host in this build; mounts fail closed). Left, - /// Right edge (vertical bands are not painted yet). + /// Right edge (no vertical band host in this build; mounts fail closed). Right, } @@ -88,8 +88,12 @@ pub const fn ui_slot_placement(slot: UiSlot) -> UiSlotPlacement { match slot { UiSlot::Top => UiSlotPlacement::Band(BandEdge::Top), UiSlot::Bottom => UiSlotPlacement::Band(BandEdge::Bottom), - UiSlot::Left => UiSlotPlacement::Band(BandEdge::Left), - UiSlot::Right => UiSlotPlacement::Band(BandEdge::Right), + // CTX-1341: no vertical band host exists yet. Admitting these mounts + // would return a handle the renderer can never paint, reserve, or + // hit-test, so they fail closed at mount time instead. + UiSlot::Left | UiSlot::Right => { + UiSlotPlacement::Unsupported("has no vertical band host in this build yet") + } UiSlot::Tabline => UiSlotPlacement::Unsupported( "is reserved for panel tabs (PW-10) and has no host surface yet", ), @@ -224,10 +228,8 @@ mod tests { let expected = match slot { UiSlot::Top => UiSlotPlacement::Band(BandEdge::Top), UiSlot::Bottom => UiSlotPlacement::Band(BandEdge::Bottom), - UiSlot::Left => UiSlotPlacement::Band(BandEdge::Left), - UiSlot::Right => UiSlotPlacement::Band(BandEdge::Right), UiSlot::Overlay => UiSlotPlacement::Overlay, - UiSlot::Tabline | UiSlot::Terminal => { + UiSlot::Left | UiSlot::Right | UiSlot::Tabline | UiSlot::Terminal => { assert!(matches!( ui_slot_placement(slot), UiSlotPlacement::Unsupported(_) @@ -245,8 +247,6 @@ mod tests { block("a", UiSlot::Top), block("b", UiSlot::Bottom), block("c", UiSlot::Bottom), - block("d", UiSlot::Left), - block("e", UiSlot::Right), ]); assert_eq!(unplaced, 0); let ids = |edge| { @@ -261,8 +261,26 @@ mod tests { ids(BandEdge::Bottom), [("b", UiSlot::Bottom), ("c", UiSlot::Bottom)] ); - assert_eq!(ids(BandEdge::Left), [("d", UiSlot::Left)]); - assert_eq!(ids(BandEdge::Right), [("e", UiSlot::Right)]); + assert!(ids(BandEdge::Left).is_empty()); + assert!(ids(BandEdge::Right).is_empty()); + } + + #[test] + fn vertical_slots_are_unsupported_and_never_routed_to_a_band() { + // CTX-1341: left/right used to map to a band that nothing painted. + // The placement policy now fails closed at mount, and any block that + // somehow reaches routing is counted as unplaced rather than stored. + for slot in [UiSlot::Left, UiSlot::Right] { + let UiSlotPlacement::Unsupported(reason) = ui_slot_placement(slot) else { + panic!("{slot} must be unsupported"); + }; + assert!(reason.contains("vertical band")); + } + let (bands, unplaced) = + ChromeBands::from_mounts([block("l", UiSlot::Left), block("r", UiSlot::Right)]); + assert_eq!(unplaced, 2); + assert!(bands.top.is_empty() && bands.bottom.is_empty()); + assert!(bands.left.is_empty() && bands.right.is_empty()); } #[test] @@ -318,13 +336,15 @@ mod tests { #[test] fn unsupported_error_is_typed() { - let UiSlotPlacement::Unsupported(reason) = ui_slot_placement(UiSlot::Tabline) else { - panic!("tabline must be unsupported"); - }; - let error = unsupported_slot_error(UiSlot::Tabline, reason); - assert_eq!(error.code, E_UI_UNAVAILABLE); - assert_eq!(error.class, "runtime"); - assert!(error.message.contains("'tabline'")); - assert!(error.message.contains(reason)); + for slot in [UiSlot::Tabline, UiSlot::Left, UiSlot::Right] { + let UiSlotPlacement::Unsupported(reason) = ui_slot_placement(slot) else { + panic!("{slot} must be unsupported"); + }; + let error = unsupported_slot_error(slot, reason); + assert_eq!(error.code, E_UI_UNAVAILABLE); + assert_eq!(error.class, "runtime"); + assert!(error.message.contains(&format!("'{slot}'"))); + assert!(error.message.contains(reason)); + } } } diff --git a/crates/bitty-runtime/src/runtime/present.rs b/crates/bitty-runtime/src/runtime/present.rs index ffcaa1ae6..0903ae2ea 100644 --- a/crates/bitty-runtime/src/runtime/present.rs +++ b/crates/bitty-runtime/src/runtime/present.rs @@ -3749,6 +3749,10 @@ impl Runtime { let default_bg = self.config.theme.background; // Owned snapshot first: the renderer takes `&mut self` below. + // + // Only horizontal edges exist here: `ui_slot_placement` rejects + // `left`/`right` at mount until a vertical band host ships + // (CTX-1341), so no vertical block can reach the paint plan. let mut plan: Vec<(BandEdge, u16, super::BandContent)> = Vec::new(); for edge in [BandEdge::Top, BandEdge::Bottom] { for (index, band) in self.visible_edge_bands(edge).into_iter().enumerate() { diff --git a/crates/bitty-runtime/tests/plugin_ui.rs b/crates/bitty-runtime/tests/plugin_ui.rs index bf9e886d9..0ad57ab23 100644 --- a/crates/bitty-runtime/tests/plugin_ui.rs +++ b/crates/bitty-runtime/tests/plugin_ui.rs @@ -538,8 +538,6 @@ fn lua_band_slot_mounts_land_in_the_expected_band() { bitty.ui.mount("bottom", { kind = "Text", text = "status" }) bitty.ui.mount("top", { kind = "Text", text = "top" }) bitty.ui.mount("bottom", { kind = "Text", text = "bottom" }) - bitty.ui.mount("left", { kind = "Text", text = "left" }) - bitty.ui.mount("right", { kind = "Text", text = "right" }) local ok, err = pcall(bitty.ui.mount, "nowhere", { kind = "Text", text = "x" }) bitty.store.set("unknown_code", ok and "NONE" or err.code) return {} @@ -567,8 +565,56 @@ fn lua_band_slot_mounts_land_in_the_expected_band() { assert_eq!(slots(BandEdge::Top), [UiSlot::Top]); // One plugin, two bottom-edge mounts: mount order is kept. assert_eq!(slots(BandEdge::Bottom), [UiSlot::Bottom, UiSlot::Bottom]); - assert_eq!(slots(BandEdge::Left), [UiSlot::Left]); - assert_eq!(slots(BandEdge::Right), [UiSlot::Right]); + assert!(slots(BandEdge::Left).is_empty()); + assert!(slots(BandEdge::Right).is_empty()); +} + +/// CTX-1341 / #2226: `left` and `right` used to return a successful handle +/// even though the renderer never painted them, reserved no columns, and had +/// no hit target. They now fail closed at mount with the existing typed +/// `E_UI_UNAVAILABLE` code, so silent success is impossible. +#[test] +fn vertical_band_slots_fail_closed_at_mount() { + let fixture = Fixture::activate( + "vertical-slots", + "bitty-featured.uivertical", + &["ui.rich"], + &[], + r#" + local left_ok, left_err = pcall(bitty.ui.mount, "left", { kind = "Text", text = "left" }) + local right_ok, right_err = pcall(bitty.ui.mount, "right", { kind = "Text", text = "right" }) + bitty.store.set("left_ok", left_ok) + bitty.store.set("left_code", left_ok and "NONE" or left_err.code) + bitty.store.set("left_message", left_ok and "" or left_err.message) + bitty.store.set("right_ok", right_ok) + bitty.store.set("right_code", right_ok and "NONE" or right_err.code) + bitty.store.set("right_message", right_ok and "" or right_err.message) + return {} + "#, + ); + + for slot in ["left", "right"] { + assert_eq!( + store_value(&fixture.runtime, &fixture.id, &format!("{slot}_ok")), + Some(LuaValue::Bool(false)), + "{slot} mount must not succeed silently" + ); + assert_eq!( + store_value(&fixture.runtime, &fixture.id, &format!("{slot}_code")), + Some(LuaValue::String(E_UI_UNAVAILABLE.to_string())), + "{slot} mount must fail with the typed code" + ); + let message = store_value(&fixture.runtime, &fixture.id, &format!("{slot}_message")); + let Some(LuaValue::String(message)) = message else { + panic!("{slot} mount must carry an error message"); + }; + assert!(message.contains(&format!("'{slot}'")), "{message}"); + assert!(message.contains("vertical band"), "{message}"); + } + + // Nothing was retained: a rejected mount leaves no phantom block that + // could later be painted or routed. + assert!(fixture.runtime.ui_blocks().is_empty()); } #[test]