Skip to content

Commit 5dbbf94

Browse files
committed
feat: empty vs compartment state sizing with hide empty description
1 parent b4bff39 commit 5dbbf94

6 files changed

Lines changed: 222 additions & 53 deletions

File tree

uml/core/style/style.mbt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,16 @@ pub fn parse_style_directive(line : String) -> StyleDirective? {
203203
if lower == "top to bottom direction" {
204204
return Some(Direction(TopToBottom))
205205
}
206-
if lower == "hide empty description" {
207-
return Some(Property("hide empty description", "true"))
206+
if lower == "hide empty description" || lower == "show empty description" {
207+
return Some(
208+
Property(
209+
"hide empty description",
210+
{
211+
guard lower == "hide empty description" else { "false" }
212+
"true"
213+
},
214+
),
215+
)
208216
}
209217
if lower.has_prefix("set namespace_separator ") ||
210218
lower.has_prefix("set namespaceseparator ") {

uml/core/style/style_test.mbt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,19 @@ test "distinguishes legend alignment headers from single-line legends" {
126126
Some(TextBlock("header", "Report")),
127127
)
128128
}
129+
130+
///|
131+
test "empty description visibility directives preserve their latest value" {
132+
@test.assert_eq(
133+
parse_style_directive("hide empty description"),
134+
Some(Property("hide empty description", "true")),
135+
)
136+
@test.assert_eq(
137+
parse_style_directive("show empty description"),
138+
Some(Property("hide empty description", "false")),
139+
)
140+
let style = DocumentStyle::empty()
141+
style.apply(Property("hide empty description", "true"))
142+
style.apply(Property("hide empty description", "false"))
143+
@test.assert_eq(style.property("hide empty description"), Some("false"))
144+
}

uml/state/ast.mbt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ pub struct StateNode {
3131
color : String?
3232
} derive(Eq, Debug)
3333

34+
///|
35+
fn StateNode::has_description(self : StateNode) -> Bool {
36+
match self.description {
37+
Some(text) => text.length() > 0
38+
None => false
39+
}
40+
}
41+
3442
///|
3543
pub struct StateTransition {
3644
source : String
@@ -76,6 +84,11 @@ pub struct StateDiagram {
7684
style : @style.DocumentStyle
7785
} derive(Eq, Debug)
7886

87+
///|
88+
fn StateDiagram::hide_empty_description(self : StateDiagram) -> Bool {
89+
self.style.property("hide empty description") == Some("true")
90+
}
91+
7992
///|
8093
pub fn StateDiagram::state(self : StateDiagram, code : String) -> StateNode? {
8194
for state in self.states {

uml/state/layout.mbt

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,22 @@ const STATE_HEADER_HEIGHT : Double = 26.0
7373
const STATE_REGION_GAP : Double = 18.0
7474

7575
///|
76-
const STATE_NODE_MIN_WIDTH : Double = 72.0
76+
const STATE_EMPTY_NODE_MIN_WIDTH : Double = 50.0
7777

7878
///|
79-
const STATE_NODE_MIN_HEIGHT : Double = 46.0
79+
const STATE_EMPTY_NODE_MIN_HEIGHT : Double = 40.0
80+
81+
///|
82+
const STATE_COMPARTMENT_MIN_WIDTH : Double = 50.0
83+
84+
///|
85+
const STATE_COMPARTMENT_MIN_HEIGHT : Double = 50.0
86+
87+
///|
88+
const STATE_EMPTY_NODE_PADDING : Double = 10.0
89+
90+
///|
91+
const STATE_COMPARTMENT_PADDING : Double = 20.0
8092

8193
///|
8294
const STATE_SYNC_WIDTH : Double = 96.0
@@ -356,24 +368,35 @@ fn state_note_size(
356368
///|
357369
fn StateNode::leaf_size(
358370
self : StateNode,
359-
metrics : @metrics.FontMetrics,
371+
name_metrics : @metrics.FontMetrics,
372+
description_metrics : @metrics.FontMetrics,
373+
hide_empty_description : Bool,
360374
) -> (Double, Double) {
361375
match self.kind {
362376
Initial | Final | History | DeepHistory | Choice =>
363377
(STATE_TERMINAL_SIZE, STATE_TERMINAL_SIZE)
364378
Synchronization => (STATE_SYNC_WIDTH, STATE_SYNC_HEIGHT)
365379
Normal => {
366-
let description = self.description.unwrap_or("")
367-
let text = if description.length() == 0 {
368-
self.display
380+
let name = state_text_size(self.display, name_metrics)
381+
if !self.has_description() && hide_empty_description {
382+
(
383+
(name.0 + STATE_EMPTY_NODE_PADDING).max(STATE_EMPTY_NODE_MIN_WIDTH),
384+
(name.1 + STATE_EMPTY_NODE_PADDING).max(STATE_EMPTY_NODE_MIN_HEIGHT),
385+
)
369386
} else {
370-
self.display + "\n" + description
387+
let description = match self.description {
388+
Some(text) => state_text_size(text, description_metrics)
389+
None => (0.0, 0.0)
390+
}
391+
(
392+
(name.0.max(description.0) + STATE_COMPARTMENT_PADDING).max(
393+
STATE_COMPARTMENT_MIN_WIDTH,
394+
),
395+
(name.1 + description.1 + STATE_COMPARTMENT_PADDING).max(
396+
STATE_COMPARTMENT_MIN_HEIGHT,
397+
),
398+
)
371399
}
372-
let (width, height) = state_text_size(text, metrics)
373-
(
374-
(width + 28.0).max(STATE_NODE_MIN_WIDTH),
375-
(height + 20.0).max(STATE_NODE_MIN_HEIGHT),
376-
)
377400
}
378401
}
379402
}
@@ -382,26 +405,30 @@ fn StateNode::leaf_size(
382405
fn StateDiagram::composite_fragment(
383406
self : StateDiagram,
384407
state : StateNode,
385-
metrics : @metrics.FontMetrics,
408+
name_metrics : @metrics.FontMetrics,
409+
description_metrics : @metrics.FontMetrics,
386410
) -> StateFragment? raise @dot_layout.DotLayoutError {
387411
guard self.has_children(state.code) else { return None }
388-
Some(self.layout_composite_state(state, metrics))
412+
Some(self.layout_composite_state(state, name_metrics, description_metrics))
389413
}
390414

391415
///|
392416
fn StateDiagram::visible_nodes_for_scope(
393417
self : StateDiagram,
394418
scope : String?,
395419
region_index : Int,
396-
metrics : @metrics.FontMetrics,
420+
name_metrics : @metrics.FontMetrics,
421+
description_metrics : @metrics.FontMetrics,
397422
) -> Array[StateVisibleNode] raise @dot_layout.DotLayoutError {
398423
let output : Array[StateVisibleNode] = []
399424
for state in self.states {
400425
guard state.parent == scope else { continue }
401426
guard region_index < 0 || state.region_index == region_index else {
402427
continue
403428
}
404-
let child = self.composite_fragment(state, metrics)
429+
let child = self.composite_fragment(
430+
state, name_metrics, description_metrics,
431+
)
405432
output.push({ state, child })
406433
}
407434
output
@@ -433,9 +460,12 @@ fn StateDiagram::layout_scope(
433460
self : StateDiagram,
434461
scope : String?,
435462
region_index : Int,
436-
metrics : @metrics.FontMetrics,
463+
name_metrics : @metrics.FontMetrics,
464+
description_metrics : @metrics.FontMetrics,
437465
) -> StateFragment raise @dot_layout.DotLayoutError {
438-
let visible_nodes = self.visible_nodes_for_scope(scope, region_index, metrics)
466+
let visible_nodes = self.visible_nodes_for_scope(
467+
scope, region_index, name_metrics, description_metrics,
468+
)
439469
let graph = @graphviz.new_directed_graph("state", self.style)
440470
let node_names : Map[String, String] = Map([])
441471
for index, visible in visible_nodes {
@@ -446,11 +476,20 @@ fn StateDiagram::layout_scope(
446476
Some(child) =>
447477
(
448478
(child.width + STATE_INTERNAL_PADDING * 2.0).max(
449-
visible.state.leaf_size(metrics).0,
479+
visible.state.leaf_size(
480+
name_metrics,
481+
description_metrics,
482+
self.hide_empty_description(),
483+
).0,
450484
),
451485
child.height + STATE_HEADER_HEIGHT + STATE_INTERNAL_PADDING,
452486
)
453-
None => visible.state.leaf_size(metrics)
487+
None =>
488+
visible.state.leaf_size(
489+
name_metrics,
490+
description_metrics,
491+
self.hide_empty_description(),
492+
)
454493
}
455494
@graphviz.set_fixed_node_size(node, width, height)
456495
}
@@ -492,7 +531,7 @@ fn StateDiagram::layout_scope(
492531
let name = @graphviz.graphviz_note_name(note_index)
493532
note_names[name] = note_index
494533
let node = graph.node(name)
495-
let (width, height) = state_note_size(note, metrics)
534+
let (width, height) = state_note_size(note, name_metrics)
496535
@graphviz.set_fixed_node_size(node, width, height, shape="note")
497536
match self.note_anchor(note, scope) {
498537
Some(anchor) =>
@@ -647,15 +686,28 @@ fn stack_region_fragments(
647686
fn StateDiagram::layout_composite_state(
648687
self : StateDiagram,
649688
state : StateNode,
650-
metrics : @metrics.FontMetrics,
689+
name_metrics : @metrics.FontMetrics,
690+
description_metrics : @metrics.FontMetrics,
651691
) -> StateFragment raise @dot_layout.DotLayoutError {
652692
let regions = self.regions_for(state.code)
653693
if regions.is_empty() {
654-
return self.layout_scope(Some(state.code), -1, metrics)
694+
return self.layout_scope(
695+
Some(state.code),
696+
-1,
697+
name_metrics,
698+
description_metrics,
699+
)
655700
}
656701
let fragments : Array[StateFragment] = []
657702
for region in regions {
658-
fragments.push(self.layout_scope(Some(state.code), region.index, metrics))
703+
fragments.push(
704+
self.layout_scope(
705+
Some(state.code),
706+
region.index,
707+
name_metrics,
708+
description_metrics,
709+
),
710+
)
659711
}
660712
stack_region_fragments(state.code, regions, fragments)
661713
}
@@ -664,8 +716,9 @@ fn StateDiagram::layout_composite_state(
664716
pub fn StateDiagram::layout(
665717
self : StateDiagram,
666718
) -> StateLayout raise @dot_layout.DotLayoutError {
667-
let metrics = self.style.font_metrics(font_size=14)
668-
let fragment = self.layout_scope(None, -1, metrics)
719+
let name_metrics = state_font_metrics(self.style)
720+
let description_metrics = state_description_font_metrics(self.style)
721+
let fragment = self.layout_scope(None, -1, name_metrics, description_metrics)
669722
{
670723
width: fragment.width + STATE_CANVAS_PADDING * 2.0,
671724
height: fragment.height + STATE_CANVAS_PADDING * 2.0,

uml/state/parser_test.mbt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,41 @@ test "state name and description use their independent font skinparams" {
126126
@test.assert_eq(svg.contains("class=\"state-description-label\""), true)
127127
}
128128

129+
///|
130+
test "empty descriptions choose the PlantUML shape selected by the directive" {
131+
let shown = render_state_svg("@startuml\nstate Empty\n@enduml")
132+
@test.assert_eq(shown.contains("data-shape=\"compartment\""), true)
133+
@test.assert_eq(shown.contains("class=\"state-compartment-separator\""), true)
134+
@test.assert_eq(shown.contains("class=\"state-description-label\""), false)
135+
136+
let hidden = render_state_svg(
137+
"@startuml\nhide empty description\nstate Empty\n@enduml",
138+
)
139+
@test.assert_eq(hidden.contains("data-shape=\"empty\""), true)
140+
@test.assert_eq(
141+
hidden.contains("class=\"state-compartment-separator\""),
142+
false,
143+
)
144+
@test.assert_eq(hidden.contains("class=\"state-label\""), true)
145+
146+
let restored = render_state_svg(
147+
"@startuml\nhide empty description\nshow empty description\nstate Empty\n@enduml",
148+
)
149+
@test.assert_eq(restored.contains("data-shape=\"compartment\""), true)
150+
@test.assert_eq(
151+
restored.contains("class=\"state-compartment-separator\""),
152+
true,
153+
)
154+
}
155+
156+
///|
157+
test "description state keeps a separate body label and compartment" {
158+
let svg = render_state_svg("@startuml\nstate Ready : connected\n@enduml")
159+
@test.assert_eq(svg.contains("data-shape=\"compartment\""), true)
160+
@test.assert_eq(svg.contains("class=\"state-compartment-separator\""), true)
161+
@test.assert_eq(svg.contains("class=\"state-description-label\""), true)
162+
}
163+
129164
///|
130165
test "preserves declaration stereotypes and frame aliases in scope" {
131166
let diagram = parse(

0 commit comments

Comments
 (0)