Skip to content
Merged
10 changes: 10 additions & 0 deletions begin/assets/graph.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,13 @@ html, body {
stroke-width: 1.5;
fill: none;
}

.node-cell.forced {
stroke: #8e44ad;
stroke-width: 3;
}

.link.forced-edge {
stroke: #8e44ad;
stroke-width: 3;
}
18 changes: 18 additions & 0 deletions begin/assets/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,24 @@
});
}());

// Highlight forced cells (see property_model::Sheet::is_forced) and every
// constraint edge touching one: the incoming edge that produces it, and any
// outgoing edges carrying its (also guaranteed) value onward to other
// relationships. Forced cells always belong to a currently active
// relationship, so this never overlaps with the inactive-relationship
// dimming above.
(function () {
var forcedSet = new Set(data.forced || []);
cellLayer.selectAll('rect')
.classed('forced', function (d) { return forcedSet.has(d.id); });
linkLayer.selectAll('line')
.classed('forced-edge', function (d) {
var srcId = typeof d.source === 'object' ? d.source.id : d.source;
var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
return forcedSet.has(srcId) || forcedSet.has(tgtId);
});
}());

// NEW: Conditional diamond nodes (rotated rect)
condLayer.selectAll('rect')
.data(condNodes, function (d) { return d.id; })
Expand Down
83 changes: 80 additions & 3 deletions begin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,30 @@ use crate::source_panel::{SourcePanel, build_sheet};
use crate::spectrum::SpTheme;

/// Default pm-lang source: two independent bidirectional constraint systems
/// (`a × b = c` and `d × e = f`) linked by a conditional on `p`.
/// (`a × b = c` and `d × e = f`) linked by two conditionals on `p`.
///
/// - `p = 0`: the relationship `c = f` (bidirectional) becomes active.
/// - `p = 1`: the relationship `c = f × 2` (bidirectional) becomes active.
/// - Any other `p`: the two systems are independent.
/// - `p = 1`: the relationship `c = f × 2` (bidirectional) becomes active, and a
/// single-method relationship `g = c × 10` also becomes active — `g` is *forced*
/// while this branch is active (see [`property_model::Sheet::is_forced`]), so its
/// Inspector field is disabled and it is highlighted in the graph.
/// - Any other `p`: the two systems are independent and `g` is not forced.
///
/// `g`'s relationship is declared in its own `conditional p { .. }` block rather than
/// folded into the first: pm-lang groups every method in one branch into a single
/// relationship, and a relationship's forced outputs are the *intersection* of its
/// methods' pure outputs — mixing `[c] -> [g]` in with the `c`/`f` methods would make
/// that intersection empty, forcing nothing. Two conditionals sharing the same match
/// cell compose independently, so this is a distinct relationship gated on the same
/// `p == 1` condition. This also means the graph renders two diamond nodes for `p`.
pub const DEMO_SOURCE: &str = r#"sheet demo {
cell a: f64 = 2.0;
cell b: f64 = 3.0;
cell c: f64;
cell d: f64 = 4.0;
cell e: f64 = 5.0;
cell f: f64;
cell g: f64;
cell p: i32 = 0;

relationship {
Expand All @@ -45,6 +57,12 @@ pub const DEMO_SOURCE: &str = r#"sheet demo {
method [c] -> [f] { c / 2.0 }
}
}

conditional p {
1i32 => {
method [c] -> [g] { c * 10.0 }
}
}
}
"#;

Expand Down Expand Up @@ -99,3 +117,62 @@ pub fn App() -> Element {
}
}
}

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

#[test]
fn demo_source_g_not_forced_when_p_is_zero() {
let outcome = build_sheet(DEMO_SOURCE);
let (sheet, labels) = outcome.sheet_labels.expect("DEMO_SOURCE must build");
let g_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("g"))
.unwrap();
assert!(!sheet.is_forced(g_id), "g should not be forced when p == 0");
}

#[test]
fn demo_source_g_forced_when_p_is_one() {
let outcome = build_sheet(DEMO_SOURCE);
let (mut sheet, labels) = outcome.sheet_labels.expect("DEMO_SOURCE must build");
let p_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("p"))
.unwrap();
let g_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("g"))
.unwrap();

sheet.write(p_id, 1_i32).unwrap();
sheet.propagate().unwrap();

assert!(sheet.is_forced(g_id), "g should be forced when p == 1");
}

#[test]
fn demo_source_g_unforced_again_after_p_returns_to_zero() {
let outcome = build_sheet(DEMO_SOURCE);
let (mut sheet, labels) = outcome.sheet_labels.expect("DEMO_SOURCE must build");
let p_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("p"))
.unwrap();
let g_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("g"))
.unwrap();

sheet.write(p_id, 1_i32).unwrap();
sheet.propagate().unwrap();
sheet.write(p_id, 0_i32).unwrap();
sheet.propagate().unwrap();

assert!(
!sheet.is_forced(g_id),
"g should not be forced once p == 0 again"
);
}
}
61 changes: 61 additions & 0 deletions begin/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ pub struct GraphData {
pub links: Vec<LinkData>,
/// Stable IDs of cells that changed during the last `propagate()` call.
pub changed: Vec<String>,
/// Stable IDs of cells forced by an active relationship (see
/// [`property_model::Sheet::is_forced`]); consumers should disable input for these
/// cells and may render them distinctly.
pub forced: Vec<String>,
/// `true` when at least one relationship has a cached plan and constraint links are directed
/// where plans exist; `false` when no plan has been computed.
pub arrows: bool,
Expand Down Expand Up @@ -342,11 +346,13 @@ pub fn to_graph_data(sheet: &Sheet, labels: &Labels) -> GraphData {
}

let changed = sheet.changed().map(cell_node_id).collect();
let forced = sheet.forced_cells().map(cell_node_id).collect();

GraphData {
nodes,
links,
changed,
forced,
arrows,
}
}
Expand Down Expand Up @@ -486,6 +492,28 @@ mod tests {
(sheet, labels)
}

fn sheet_with_forced_conditional() -> (Sheet, Labels) {
let mut sheet = Sheet::new();
let mut labels = Labels::new();

let a = sheet.add_cell(2.0_f64);
labels.add_cell::<f64>(a, "a");
let b = sheet.add_cell(0.0_f64);
labels.add_cell::<f64>(b, "b");
let p = sheet.add_cell(0_i32);
labels.add_cell::<i32>(p, "p");

let rel = sheet
.add_relationship(vec![Method::from_fn_1_1(a, b, |v: &f64| Ok(*v))])
.unwrap();

sheet
.add_conditional(p, vec![(vec![0_i32], vec![rel])], vec![])
.unwrap();

(sheet, labels)
}

#[test]
fn to_graph_data_produces_correct_node_counts() {
let (sheet, labels) = demo_sheet();
Expand Down Expand Up @@ -705,4 +733,37 @@ mod tests {
"GraphData must not contain groups"
);
}

#[test]
fn to_graph_data_forced_field_contains_forced_cell() {
let (mut sheet, labels) = sheet_with_forced_conditional();
sheet.propagate().unwrap();

let b_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("b"))
.unwrap();

let data = to_graph_data(&sheet, &labels);
assert!(data.forced.contains(&cell_node_id(b_id)));
}

#[test]
fn to_graph_data_forced_field_excludes_cell_when_branch_inactive() {
let (mut sheet, labels) = sheet_with_forced_conditional();
let p_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("p"))
.unwrap();
sheet.write(p_id, 1_i32).unwrap();
sheet.propagate().unwrap();

let b_id = sheet
.cells()
.find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("b"))
.unwrap();

let data = to_graph_data(&sheet, &labels);
assert!(!data.forced.contains(&cell_node_id(b_id)));
}
}
3 changes: 3 additions & 0 deletions begin/src/inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ fn CellRow(
.unwrap_or_default()
});

let forced = use_memo(move || sheet.read().is_forced(id));

let mut input = use_signal(|| value.peek().clone());
let mut is_focused = use_signal(|| false);
let mut has_error = use_signal(|| false);
Expand All @@ -90,6 +92,7 @@ fn CellRow(
id: field_id,
value: input.read().clone(),
invalid: *has_error.read(),
disabled: *forced.read(),
// Dioxus's event serializer only reads event.target.value for
// HTMLInputElement — custom elements (sp-textfield) always give "".
// Use dioxus.send() in JS and eval.recv() to read the live value.
Expand Down
5 changes: 4 additions & 1 deletion begin/src/spectrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ pub fn SpTheme(color: String, scale: String, children: Element) -> Element {
///
/// Maps to `<sp-textfield>`. Fires standard DOM `input`, `focus`, and `blur`
/// events. Setting `invalid` to `true` renders the SWC error state (red ring
/// and `aria-invalid`).
/// and `aria-invalid`). Setting `disabled` to `true` renders the SWC disabled
/// state and blocks focus/input at the DOM level.
#[component]
pub fn SpTextfield(
id: String,
value: String,
invalid: bool,
disabled: bool,
oninput: EventHandler<FormEvent>,
onfocus: EventHandler<FocusEvent>,
onblur: EventHandler<FocusEvent>,
Expand All @@ -49,6 +51,7 @@ pub fn SpTextfield(
"value": "{value}",
// Boolean attribute: omit entirely when false; presence = invalid.
"invalid": if invalid { "true" },
"disabled": if disabled { "true" },
oninput: move |e| oninput.call(e),
onfocus: move |e| onfocus.call(e),
onblur: move |e| onblur.call(e),
Expand Down
Loading
Loading