Save reboot checkpoint with source layout WIP and native evidence

This commit is contained in:
2026-09-11 06:42:46 -07:00
parent c4bad4b8de
commit ca53f574e5
82 changed files with 6211 additions and 168 deletions
+574 -37
View File
@@ -325,50 +325,73 @@ impl State {
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.editing.as_ref()?;
let event = move |edit| Event::Edit(d.token, edit);
let kinds = d.scope.kinds(d.layer);
let determined = kinds.len() == 1 && d.kind == kinds.first().copied();
let mut fields = column![
text(format!("New source · {}", d.layer)).size(18),
text(match d.scope {
Scope::Lifeline { owner, viewpoint } =>
format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint),
Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)),
Scope::Machine { .. } => "Owner for the pending state machine".into(),
text(if determined {
format!("New {} · {}", kinds[0], d.layer)
} else {
format!("New source · {}", d.layer)
})
.size(12),
text("The parent form is retained. This source is created only with its consumer.")
.size(12)
]
.spacing(8);
for kind in d.scope.kinds(d.layer) {
fields = fields.push(
button(text(kind.to_string()))
.on_press(event(Edit::Kind(kind)))
.style(if d.kind == Some(kind) {
button::primary
} else {
button::secondary
}),
);
}
fields = fields.push(
.size(14),
text_input("Source name (independent of the consumer name)", &d.name)
.id(NAME_INPUT)
.on_input(move |v| event(Edit::Name(v)))
.padding(8),
);
.size(14)
.padding(6),
]
.spacing(4);
if !determined {
let mut choices = row![].spacing(6);
for kind in kinds {
choices = choices.push(
container(
button(text(kind.to_string()).size(14))
.padding(4)
.on_press(event(Edit::Kind(kind)))
.style(if d.kind == Some(kind) {
button::primary
} else {
button::secondary
}),
)
.id(format!("behavior-source-kind-{kind:?}")),
);
}
fields = fields.push(choices.wrap());
}
fields = fields.push(
text(format!(
"Semantic parent: {}",
d.parent
.as_ref()
.map_or(format!("{} layer root", d.layer), |e| format!(
"{} · {} · @{}",
e.name,
e.kind(),
e.alias
))
))
.size(12),
container(
text(format!(
"Semantic parent: {}",
d.parent
.as_ref()
.map_or(format!("{} layer root", d.layer), |e| format!(
"{} · {} · @{}",
e.name,
e.kind(),
e.alias
))
))
.size(12),
)
.id("behavior-source-parent"),
);
fields = fields
.push(
text(match d.scope {
Scope::Lifeline { owner, viewpoint } => {
format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint)
}
Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)),
Scope::Machine { .. } => "Owner for the pending state machine".into(),
})
.size(12),
)
.push(
text("The parent form is retained. This source is created only with its consumer.")
.size(12),
);
if let Some(kind) = d.kind {
fields = fields.push(button("Use layer root").on_press_maybe(
validation::can_parent(p, kind, d.layer, None).then(|| event(Edit::Parent(None))),
@@ -486,3 +509,517 @@ impl Draft {
Ok(command)
}
}
#[cfg(all(test, debug_assertions))]
mod layout_tests {
use super::*;
use iced::advanced::{Layout, Shell, graphics, layout, renderer, text as shaped, widget};
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
// Use Iced's real paragraph shaping/measurement without a window or GPU.
// The unit renderer's zero-size paragraphs cannot detect this regression.
struct FontLayout;
impl renderer::Renderer for FontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
) + Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut (), handle, callback);
}
}
impl shaped::Renderer for FontLayout {
type Font = Font;
type Paragraph = graphics::text::Paragraph;
type Editor = ();
const ICON_FONT: Font = Font::DEFAULT;
const CHECKMARK_ICON: char = '0';
const ARROW_DOWN_ICON: char = '0';
const SCROLL_UP_ICON: char = '0';
const SCROLL_DOWN_ICON: char = '0';
const SCROLL_LEFT_ICON: char = '0';
const SCROLL_RIGHT_ICON: char = '0';
const ICED_LOGO: char = '0';
fn default_font(&self) -> Font {
Font::DEFAULT
}
fn default_size(&self) -> Pixels {
Pixels(16.)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
#[derive(Default)]
struct Geometry {
fields: Option<Rectangle>,
name: Option<Rectangle>,
parent: Option<Rectangle>,
actions: Option<Rectangle>,
kinds: BTreeMap<String, Rectangle>,
text: Vec<(String, Rectangle)>,
focus_name: bool,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("behavior-source-actions")) {
self.actions = Some(bounds);
}
if id == Some(&widget::Id::new("behavior-source-parent")) {
self.parent = Some(bounds);
}
for kind in [
Kind::Entity,
Kind::Actor,
Kind::Role,
Kind::System,
Kind::SystemActor,
Kind::Activity,
Kind::Function,
] {
if id == Some(&widget::Id::from(format!("behavior-source-kind-{kind:?}"))) {
self.kinds.insert(kind.to_string(), bounds);
}
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("behavior-source-fields")) {
assert_eq!(
offset,
Vector::ZERO,
"Initial fields must be visible without scrolling"
);
self.fields = Some(bounds);
}
}
fn text_input(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn widget::operation::TextInput,
) {
if id == Some(&widget::Id::new(NAME_INPUT)) {
self.name = Some(bounds);
}
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
if self.focus_name && id == Some(&widget::Id::new(NAME_INPUT)) {
state.focus();
}
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
self.text.push((text.into(), bounds));
}
}
fn contains(outer: Rectangle, inner: Rectangle) -> bool {
inner.width > 0.
&& inner.height > 0.
&& inner.x >= outer.x - 0.1
&& inner.y >= outer.y - 0.1
&& inner.x + inner.width <= outer.x + outer.width + 0.1
&& inner.y + inner.height <= outer.y + outer.height + 0.1
}
fn host<'a>(
state: &'a State,
p: &'a Project,
caption: &str,
) -> UiElement<'a, Event, iced::Theme, FontLayout> {
iced::widget::column![text(caption.to_owned()).size(12), state.view(p).unwrap()]
.spacing(6)
.height(Fill)
.width(Fill)
.into()
}
fn geometry(
view: &mut UiElement<'_, Event, iced::Theme, FontLayout>,
tree: &mut widget::Tree,
size: Size,
) -> (layout::Node, Geometry) {
let node =
view.as_widget_mut()
.layout(tree, &FontLayout, &layout::Limits::new(Size::ZERO, size));
let mut geometry = Geometry::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &FontLayout, &mut geometry);
(node, geometry)
}
fn fixture(layer: Layer, viewpoint: Option<ScenarioViewpoint>, work: bool) -> (Project, State) {
let mut p = Project::empty();
let scope = if work {
let owner = p
.add_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Consumer",
None,
)
.unwrap();
Scope::Work { owner }
} else if let Some(viewpoint) = viewpoint {
let owner = p
.add_element(
Kind::Scenario,
layer,
"Incident coordination with a long contextual heading 雨 Ω",
None,
)
.unwrap();
if let Some(SharedBody::Scenario { viewpoint: v, .. }) =
p.elements.get_mut(&owner).unwrap().shared_mut()
{
*v = viewpoint;
}
Scope::Lifeline { owner, viewpoint }
} else {
Scope::Machine { layer }
};
let mut state = State::default();
let invocation = Id::new_v4();
state
.update(&p, scope, invocation, Event::Open(invocation))
.unwrap();
(p, state)
}
#[test]
fn contextual_source_layout_initial_essentials_fit_short_composed_host_with_real_paragraphs() {
for (layer, viewpoint, work) in [
(
Layer::Oa,
Some(ScenarioViewpoint::OperationalActivity),
false,
),
(Layer::Sa, Some(ScenarioViewpoint::Functional), false),
(
Layer::Oa,
Some(ScenarioViewpoint::ParticipantInteraction),
false,
),
(Layer::Sa, Some(ScenarioViewpoint::BoundaryInterface), false),
(Layer::Oa, None, false),
(Layer::Sa, None, false),
(Layer::Oa, None, true),
(Layer::Sa, None, true),
] {
let (p, state) = fixture(layer, viewpoint, work);
let raw = serde_json::to_value(&state).unwrap();
for width in [340., 500.] {
// The retained caption + 6px gap leaves approximately the observed 194px card.
let size = Size::new(width, 215.);
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, size);
let fields = g.fields.unwrap();
assert!(
(125. ..=132.).contains(&fields.height),
"Measured field viewport: {fields:?}"
);
assert!(
contains(fields, g.name.unwrap()),
"Name clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
);
assert!(
contains(fields, g.parent.unwrap()),
"Parent clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
);
assert!(contains(
Rectangle::new(Point::ORIGIN, size),
g.actions.unwrap()
));
assert!(fields.y + fields.height <= g.actions.unwrap().y);
let expected = state.editing.as_ref().unwrap().scope.kinds(layer);
assert_eq!(
g.kinds.len(),
if expected.len() == 1 {
0
} else {
expected.len()
}
);
for bounds in g.kinds.values() {
assert!(
contains(fields, *bounds),
"Kind clipped: {bounds:?} in {fields:?}"
);
}
let parent_text = g
.text
.iter()
.find(|(s, _)| s.starts_with("Semantic parent:"))
.unwrap();
assert_eq!(
parent_text.0,
format!("Semantic parent: {layer} layer root")
);
assert!(
parent_text.1.height > 0.,
"Paragraph measurement must not be null"
);
let explanation = g
.text
.iter()
.find(|(s, _)| s.starts_with("The parent form"))
.unwrap();
assert!(explanation.1.y >= g.parent.unwrap().y + g.parent.unwrap().height);
}
assert_eq!(
serde_json::to_value(&state).unwrap(),
raw,
"Layout must not choose a kind or mutate the draft"
);
if state.editing.as_ref().unwrap().scope.kinds(layer).len() == 1 {
let mut incomplete = state.clone();
incomplete.editing.as_mut().unwrap().kind = None;
let mut view = host(&incomplete, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
assert_eq!(
g.kinds.len(),
1,
"An incomplete recovery still needs an explicit repair choice"
);
assert!(contains(
g.fields.unwrap(),
*g.kinds.values().next().unwrap()
));
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
assert_eq!(incomplete.editing.as_ref().unwrap().kind, None);
}
}
}
#[test]
fn contextual_source_layout_long_parent_wraps_after_visible_name_without_losing_raw_data() {
let (mut p, mut state) = fixture(Layer::Oa, None, true);
let parent = p
.add_element(
Kind::Activity,
Layer::Oa,
"Northern operations coordination — exact parent 雨",
None,
)
.unwrap();
let d = state.editing.as_ref().unwrap();
let token = d.token;
let scope = d.scope;
let invocation = Id::new_v4();
state
.update(
&p,
scope,
invocation,
Event::Edit(token, Edit::Parent(Some(parent))),
)
.unwrap();
state
.update(
&p,
scope,
invocation,
Event::Edit(token, Edit::Name(" Distinct source 雨 ".into())),
)
.unwrap();
let raw = serde_json::to_value(&state).unwrap();
for width in [340., 500.] {
let size = Size::new(width, 235.);
let mut view = host(
&state,
&p,
"Consumer name: Coordination with a long independent label 雨 Ω",
);
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, size);
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
assert!(contains(g.fields.unwrap(), g.parent.unwrap()));
assert!(contains(
Rectangle::new(Point::ORIGIN, size),
g.actions.unwrap()
));
let summary = g
.text
.iter()
.find(|(s, _)| s.starts_with("Semantic parent:"))
.unwrap();
assert!(summary.0.contains(&p.elements[&parent].name));
assert!(summary.0.contains(&p.elements[&parent].alias));
assert!(
summary.1.height > 15.,
"Long parent should exercise real wrapping"
);
}
assert_eq!(serde_json::to_value(&state).unwrap(), raw);
assert_eq!(
state.editing.as_ref().unwrap().name,
" Distinct source 雨 "
);
let (mut p, mut state) = fixture(
Layer::Oa,
Some(ScenarioViewpoint::ParticipantInteraction),
false,
);
let parent = p
.add_element(
Kind::Entity,
Layer::Oa,
&"Northern operational responsibility 雨 ".repeat(8),
None,
)
.unwrap();
let d = state.editing.as_ref().unwrap();
let token = d.token;
let scope = d.scope;
state
.update(
&p,
scope,
Id::new_v4(),
Event::Edit(token, Edit::Kind(Kind::Actor)),
)
.unwrap();
state
.update(
&p,
scope,
Id::new_v4(),
Event::Edit(token, Edit::Parent(Some(parent))),
)
.unwrap();
let raw = serde_json::to_value(&state).unwrap();
let recovered: State = serde_json::from_value(raw.clone()).unwrap();
let mut view = host(&recovered, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
let fields = g.fields.unwrap();
assert!(contains(fields, g.name.unwrap()));
assert_eq!(g.kinds.len(), 3);
for bounds in g.kinds.values() {
assert!(
contains(fields, *bounds),
"Long parent must not hide a semantic choice"
);
assert!(bounds.y + bounds.height <= g.parent.unwrap().y);
}
// Arbitrary authored detail may scroll, but its label and first line are
// visible after the required kind controls, never in front of them.
let parent_bounds = g.parent.unwrap();
assert!(parent_bounds.y + 16. <= fields.y + fields.height);
assert!(
parent_bounds.height > fields.height,
"Exercise genuinely overflowing parent text"
);
let summary = g
.text
.iter()
.find(|(s, _)| s.starts_with("Semantic parent:"))
.unwrap();
assert!(summary.0.contains(&p.elements[&parent].name));
assert!(summary.0.contains(&p.elements[&parent].alias));
assert!(contains(
Rectangle::new(Point::ORIGIN, Size::new(340., 215.)),
g.actions.unwrap()
));
assert_eq!(serde_json::to_value(&recovered).unwrap(), raw);
}
#[test]
fn contextual_source_layout_visible_kind_click_keeps_choice_explicit_and_name_input_works() {
let (p, mut state) = fixture(
Layer::Oa,
Some(ScenarioViewpoint::ParticipantInteraction),
false,
);
let original = p.clone();
let token = state.editing.as_ref().unwrap().token;
let scope = state.editing.as_ref().unwrap().scope;
assert_eq!(state.editing.as_ref().unwrap().kind, None);
let size = Size::new(340., 215.);
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (node, g) = geometry(&mut view, &mut tree, size);
let mut messages = Vec::new();
for kind in [Kind::Entity, Kind::Actor, Kind::Role] {
let bounds = g.kinds[&kind.to_string()];
assert!(contains(g.fields.unwrap(), bounds));
for action in [
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left),
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left),
] {
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(action),
Layout::new(&node),
iced::mouse::Cursor::Available(bounds.center()),
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::new(Point::ORIGIN, size),
);
}
assert!(
matches!(messages.pop(), Some(Event::Edit(id, Edit::Kind(k))) if id == token && k == kind)
);
}
let mut focus = Geometry {
focus_name: true,
..Geometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut focus);
let event = iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Character("Ω".into()),
modified_key: iced::keyboard::Key::Character("Ω".into()),
physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::KeyA),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: Some("Ω".into()),
repeat: false,
});
view.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::new(Point::ORIGIN, size),
);
let edit = messages.pop().unwrap();
assert!(matches!(&edit, Event::Edit(id, Edit::Name(name)) if *id == token && name == "Ω"));
drop(view);
state.update(&p, scope, Id::new_v4(), edit).unwrap();
assert_eq!(
state.editing.as_ref().unwrap().kind,
None,
"Rendering/click event production must not silently adopt a kind"
);
assert_eq!(state.editing.as_ref().unwrap().name, "Ω");
assert_eq!(p, original);
}
}