Skip to content
19 changes: 14 additions & 5 deletions lp-app/lpa-studio-core/src/app/node/face/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,33 @@
//! hand-built per node kind; only the front-panel metadata (which slots are
//! on the panel, widget kind, range/unit) is data-driven from slot shapes.
//!
//! Faces exist for shader, fixture, and playlist nodes, derived from the
//! finished section DTOs in `node_face_builder` (project layer) so a panel
//! control and its backing slot row can never disagree. Every other kind
//! gets `None` and renders today's generic sections — the universal
//! fallback, also always available inside the advanced drawer.
//! Faces exist for shader, fixture, playlist, button, and output nodes,
//! derived from the finished section DTOs in `node_face_builder` (project
//! layer) so a panel control and its backing slot row can never disagree.
//! Every other kind gets `None` and renders today's generic sections — the
//! universal fallback, also always available inside the advanced drawer.
//!
//! The button and output faces are the runtime-command channel's faces:
//! their affordance is not an edit at all but a poke at the live runtime
//! (simulate a press; drive a diagnostic pattern), so they carry a node
//! ADDRESS and action constructors rather than slot addresses.

mod ui_button_face;
mod ui_fixture_face;
mod ui_fixture_power;
mod ui_node_face;
mod ui_output_face;
mod ui_panel_control;
mod ui_panel_widget;
mod ui_playlist_entry;
mod ui_playlist_face;
mod ui_shader_face;

pub use ui_button_face::UiButtonFace;
pub use ui_fixture_face::UiFixtureFace;
pub use ui_fixture_power::UiFixturePower;
pub use ui_node_face::UiNodeFace;
pub use ui_output_face::UiOutputFace;
pub use ui_panel_control::UiPanelControl;
pub use ui_panel_widget::UiPanelWidget;
pub use ui_playlist_entry::UiPlaylistEntry;
Expand Down
107 changes: 107 additions & 0 deletions lp-app/lpa-studio-core/src/app/node/face/ui_button_face.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! The button card's permanent face.

use lpc_wire::WireButtonEvent;

use crate::{ButtonEventOp, ControllerId, ProjectController, ProjectNodeAddress, UiAction};

/// Permanent face for a button node card.
///
/// The face is one affordance — simulate a press from the Studio, so a show
/// can be rehearsed (and a binding proven) without reaching for the physical
/// button. The endpoint and message id ride along as the button's identity;
/// everything else stays in the advanced drawer's slot view.
///
/// The face does NOT carry a ready-made action, because a hold's `press_id`
/// is minted per gesture in the view layer. It carries the ADDRESS and the
/// three constructors below, so the op type and its controller routing stay
/// in core while the timing (press window, renewal cadence) stays where the
/// pointer events are.
#[derive(Clone, Debug, PartialEq)]
pub struct UiButtonFace {
/// Stable address of the button node the simulate-press control pokes.
pub node: ProjectNodeAddress,
/// Authored hardware endpoint (`button:gpio:D9`), when the def's row
/// resolved — the button's physical identity, shown beside the control.
pub endpoint: Option<String>,
/// Authored stable message id the button publishes under, when the
/// def's row resolved.
pub id: Option<u32>,
}

impl UiButtonFace {
/// A tap: the minimal down-then-up pair, no hold to manage.
pub fn click_action(&self) -> UiAction {
self.action(WireButtonEvent::Click)
}

/// Begin — or RENEW — a sustained hold. Renewals re-send this same
/// action (same `press_id`); the runtime auto-releases at `ttl_ms` if
/// they stop, which is what makes an unmounted card safe.
pub fn press_action(&self, press_id: u32, ttl_ms: u32) -> UiAction {
self.action(WireButtonEvent::Press { press_id, ttl_ms })
}

/// End the hold started with `press_id`.
pub fn release_action(&self, press_id: u32) -> UiAction {
self.action(WireButtonEvent::Release { press_id })
}

fn action(&self, event: WireButtonEvent) -> UiAction {
UiAction::from_op(
ControllerId::new(ProjectController::NODE_ID),
ButtonEventOp {
node: self.node.clone(),
event,
},
)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn face() -> UiButtonFace {
UiButtonFace {
node: ProjectNodeAddress::parse("/demo.project/panel.button").unwrap(),
endpoint: Some("button:gpio:D9".to_string()),
id: Some(1),
}
}

#[test]
fn every_event_targets_the_project_controller_with_the_faces_address() {
let face = face();

for action in [
face.click_action(),
face.press_action(7, 5000),
face.release_action(7),
] {
assert!(action.is_for_node(ProjectController::NODE_ID));
let op = action
.op_as::<ButtonEventOp>()
.expect("a button event op rides the action");
assert_eq!(op.node, face.node);
}
}

#[test]
fn a_renewal_rebuilds_the_identical_press() {
let face = face();
assert_eq!(
face.press_action(7, 5000).op_as::<ButtonEventOp>().unwrap(),
face.press_action(7, 5000).op_as::<ButtonEventOp>().unwrap(),
);
assert_eq!(
face.press_action(7, 5000)
.op_as::<ButtonEventOp>()
.unwrap()
.event,
WireButtonEvent::Press {
press_id: 7,
ttl_ms: 5000,
},
);
}
}
7 changes: 6 additions & 1 deletion lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The kind-specific face variants a node card can render.

use crate::{UiFixtureFace, UiPlaylistFace, UiShaderFace};
use crate::{UiButtonFace, UiFixtureFace, UiOutputFace, UiPlaylistFace, UiShaderFace};

/// Kind-specific permanent face for a node card.
///
Expand All @@ -16,4 +16,9 @@ pub enum UiNodeFace {
/// Playlist card: entry strip; the active child's real card renders
/// below via the existing [`crate::UiNodeChild`].
Playlist(UiPlaylistFace),
/// Button card: the simulate-press control (a runtime poke, not an
/// edit).
Button(UiButtonFace),
/// Output card: the test-pattern toggle (a runtime poke, not an edit).
Output(UiOutputFace),
}
118 changes: 118 additions & 0 deletions lp-app/lpa-studio-core/src/app/node/face/ui_output_face.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//! The output card's permanent face.

use lpc_wire::WireOutputTestPattern;

use crate::{
ControllerId, OutputTestPatternOp, ProjectController, ProjectNodeAddress, UiAction,
UiProducedProduct,
};

/// Permanent face for an output node card.
///
/// The face is one affordance — drive the strip with a diagnostic pattern —
/// answering the first question anyone asks of hardware: are these LEDs
/// wired, addressed, and alive at all? The endpoint rides along as the
/// output's identity; everything else stays in the advanced drawer's slot
/// view.
///
/// Like [`crate::UiButtonFace`] the face carries the ADDRESS plus the two
/// constructors below rather than a ready-made action: the op type and its
/// controller routing stay in core, the renewal cadence stays where the
/// timers are.
#[derive(Clone, Debug, PartialEq)]
pub struct UiOutputFace {
/// Stable address of the output node the test pattern is aimed at.
pub node: ProjectNodeAddress,
/// Authored hardware endpoint (`ws281x:rmt:D10`), when the def's row
/// resolved — the output's physical identity.
pub endpoint: Option<String>,
/// What the output is currently being fed, when the node publishes a
/// control product to preview.
pub preview: Option<UiProducedProduct>,
}

impl UiOutputFace {
/// Start — or RENEW — full-white test output. The runtime restores the
/// graph's own frames at `ttl_ms` if the renewals stop, which is what
/// makes an unmounted card (or a closed tab) safe.
pub fn test_pattern_action(&self, ttl_ms: u32) -> UiAction {
self.action(
WireOutputTestPattern::Solid {
r: 255,
g: 255,
b: 255,
},
ttl_ms,
)
}

/// End test-pattern mode now, without waiting for the TTL.
pub fn clear_action(&self) -> UiAction {
self.action(WireOutputTestPattern::Clear, 0)
}

fn action(&self, pattern: WireOutputTestPattern, ttl_ms: u32) -> UiAction {
UiAction::from_op(
ControllerId::new(ProjectController::NODE_ID),
OutputTestPatternOp {
node: self.node.clone(),
pattern,
ttl_ms,
},
)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn face() -> UiOutputFace {
UiOutputFace {
node: ProjectNodeAddress::parse("/demo.project/strip.output").unwrap(),
endpoint: Some("ws281x:rmt:D10".to_string()),
preview: None,
}
}

#[test]
fn both_sends_target_the_project_controller_with_the_faces_address() {
let face = face();

for action in [face.test_pattern_action(2000), face.clear_action()] {
assert!(action.is_for_node(ProjectController::NODE_ID));
let op = action
.op_as::<OutputTestPatternOp>()
.expect("a test-pattern op rides the action");
assert_eq!(op.node, face.node);
}
}

#[test]
fn the_pattern_is_full_white_and_clearing_carries_no_ttl() {
let face = face();

let on = face
.test_pattern_action(2000)
.op_as::<OutputTestPatternOp>()
.unwrap()
.clone();
assert_eq!(
on.pattern,
WireOutputTestPattern::Solid {
r: 255,
g: 255,
b: 255,
}
);
assert_eq!(on.ttl_ms, 2000);

let off = face
.clear_action()
.op_as::<OutputTestPatternOp>()
.unwrap()
.clone();
assert_eq!(off.pattern, WireOutputTestPattern::Clear);
assert_eq!(off.ttl_ms, 0);
}
}
4 changes: 2 additions & 2 deletions lp-app/lpa-studio-core/src/app/node/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ mod ui_slot_unit;
mod ui_slot_value;

pub use face::{
UiFixtureFace, UiFixturePower, UiNodeFace, UiPanelControl, UiPanelWidget, UiPlaylistEntry,
UiPlaylistFace, UiShaderFace,
UiButtonFace, UiFixtureFace, UiFixturePower, UiNodeFace, UiOutputFace, UiPanelControl,
UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiShaderFace,
};
pub use ui_asset_editor::UiAssetEditor;
pub use ui_binding_authoring::{UiBindingAuthoring, UiBindingAuthoringDirection, UiChannelChoice};
Expand Down
8 changes: 4 additions & 4 deletions lp-app/lpa-studio-core/src/app/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ pub use asset::{
pub use dirty_summary::DirtySummary;
pub use loaded_project_choice::LoadedProjectChoice;
pub use node::{
NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp,
NodeRevertOp, PlaylistActivateOp, ProjectNodeAddress, ProjectNodeTarget,
ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry, UiAttachTarget,
UiNodeRemovePreflight,
ButtonEventOp, NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp,
NodeRemoveOp, NodeRevertOp, OutputTestPatternOp, PlaylistActivateOp, ProjectNodeAddress,
ProjectNodeTarget, ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry,
UiAttachTarget, UiNodeRemovePreflight,
};
pub use node_card_ui_state::{NodeCardDrawer, NodeCardUiState, NodeUiOp};
pub use project_connect_result::ProjectConnectResult;
Expand Down
Loading
Loading