Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/bitty-lua/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions crates/bitty-runtime/src/plugin_runtime/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 10 additions & 6 deletions crates/bitty-runtime/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,11 @@ pub struct ChromeBands {
pub top: Vec<BandContent>,
/// Bottom edge bands (index `0` at the window bottom, stacking upward).
pub bottom: Vec<BandContent>,
/// 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<BandContent>,
/// 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<BandContent>,
}

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions crates/bitty-runtime/src/runtime/band_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
62 changes: 41 additions & 21 deletions crates/bitty-runtime/src/runtime/band_slots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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,
}

Expand All @@ -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",
),
Expand Down Expand Up @@ -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(_)
Expand All @@ -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| {
Expand All @@ -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]
Expand Down Expand Up @@ -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));
}
}
}
4 changes: 4 additions & 0 deletions crates/bitty-runtime/src/runtime/present.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
54 changes: 50 additions & 4 deletions crates/bitty-runtime/tests/plugin_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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]
Expand Down
Loading