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
4 changes: 2 additions & 2 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions .changeset/group-flag-fuses-left.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
linesmith-core: major
---

**BREAKING (pre-1.0): `LineItem::Segment` gained a `fuses_left: bool` field.**

ADR-0029 adds the `group` color-grouping flag. The builder records group membership on the segment stream through a new `fuses_left` field on `LineItem::Segment` (true when a segment shares a color group with the segment to its left). Because the `Segment` variant is a public, non-`#[non_exhaustive]` struct variant, the field breaks downstream crates that construct or exhaustively match it: add `fuses_left: …` to constructions and `..` to exhaustive matches.

`[line].segments` entries gain an optional `group` flag, orthogonal to `merge`: `group = true` fuses a segment with its right neighbor for color while keeping the separator between them, and `merge = true` implies grouping unless `group = false` overrides. The group-lead color render layer consumes `fuses_left`.
8 changes: 8 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@
],
"default": null
},
"group": {
"description": "Color-grouping flag (ADR-0029), orthogonal to [`merge`](Self::merge)\nspacing: when `true` on a segment entry, the segment fuses with its\nright neighbor into one color group (rendered in the group lead's\ncolor). `None` inherits from `merge` (an abutted pair is one visual\nunit, so `merge = true` implies grouping); `Some(false)` opts out\neven when merged. Ignored (with warning) on separator entries.",
"type": [
"boolean",
"null"
],
"default": null
},
"merge": {
"description": "When `true` on a segment entry, the boundary to its right\nrenders without a separator (suppresses the implicit\ninterleave AND any explicit [`LineEntry::Item`] separator at\nthat boundary). Ignored (with warning) on separator entries.",
"type": [
Expand Down
22 changes: 22 additions & 0 deletions crates/linesmith-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ pub struct LineEntryItem {
/// interleave AND any explicit [`LineEntry::Item`] separator at
/// that boundary). Ignored (with warning) on separator entries.
pub merge: Option<bool>,
/// Color-grouping flag (ADR-0029), orthogonal to [`merge`](Self::merge)
/// spacing: when `true` on a segment entry, the segment fuses with its
/// right neighbor into one color group (rendered in the group lead's
/// color). `None` inherits from `merge` (an abutted pair is one visual
/// unit, so `merge = true` implies grouping); `Some(false)` opts out
/// even when merged. Ignored (with warning) on separator entries.
pub group: Option<bool>,
/// Forward-compat bag: keys outside the typed fields land here
/// per the `toml::Value` flatten pattern. The builder
/// warn-and-drops unknown keys today; future ADRs may consume.
Expand Down Expand Up @@ -264,6 +271,20 @@ impl LineEntry {
_ => false,
}
}

/// The raw `group` flag (ADR-0029) on a segment entry, or `None` when
/// unset. `None` means "inherit from `merge`" at build time (an
/// abutted pair is one visual unit, so `merge = true` implies
/// grouping); `Some(false)` opts out even when merged. Always `None`
/// for separators and bare-string entries; a separator entry carrying
/// `group` warns at build time and is not honored here.
#[must_use]
pub fn group(&self) -> Option<bool> {
match self {
Self::Item(item) if item.kind.as_deref() != Some("separator") => item.group,
_ => None,
}
}
}

impl From<&str> for LineEntry {
Expand Down Expand Up @@ -325,6 +346,7 @@ fn value_to_line_entry(value: toml::Value) -> LineEntry {
kind: None,
character: None,
merge: None,
group: None,
extra,
})
}
Expand Down
2 changes: 1 addition & 1 deletion crates/linesmith-core/src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ fn collect_items_with<'a>(
let mut out: Vec<LayoutItem<'a>> = Vec::with_capacity(items.len());
for item in items {
match item {
LineItem::Segment { id, segment } => {
LineItem::Segment { id, segment, .. } => {
let defaults = segment.defaults();
let rendered = match segment.render(ctx, rc) {
Ok(Some(r)) => r,
Expand Down
113 changes: 46 additions & 67 deletions crates/linesmith-core/src/layout/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,10 @@ fn line_items_with(segments: Vec<Box<dyn Segment>>, sep: Separator) -> Vec<LineI
let n = segments.len();
let mut out = Vec::with_capacity(n.saturating_mul(2));
for (i, segment) in segments.into_iter().enumerate() {
out.push(LineItem::Segment {
id: std::borrow::Cow::Owned(format!("seg{i}")),
out.push(LineItem::seg(
std::borrow::Cow::Owned(format!("seg{i}")),
segment,
});
));
if i + 1 < n {
out.push(LineItem::Separator(sep.clone()));
}
Expand Down Expand Up @@ -1557,15 +1557,15 @@ fn emit_priority_drop_when_segment_dropped_under_pressure() {
// High-priority `DroppableStub` (priority 200) next to a
// priority-0 anchor; budget forces a drop.
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("anchor"),
segment: Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
},
LineItem::seg(
Cow::Borrowed("anchor"),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("droppable"),
segment: Box::new(DroppableStub("zzzzzz")),
},
LineItem::seg(
Cow::Borrowed("droppable"),
Box::new(DroppableStub("zzzzzz")),
),
];
// Total 1+1+6 = 8. Budget 1 drops droppable; anchor survives.
let_capturing_observers!(observers, decisions);
Expand Down Expand Up @@ -1595,18 +1595,15 @@ fn emit_shrink_applied_when_shrink_to_fit_succeeds() {
// Segment offers a shrink_to_fit form. Layout pressure forces it
// before drop.
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("shrinkable"),
segment: Box::new(ShrinkableSegment {
LineItem::seg(
Cow::Borrowed("shrinkable"),
Box::new(ShrinkableSegment {
full: "longbranch * ↑2 ↓1",
compact: "longbranch",
}),
},
),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("anchor"),
segment: Box::new(AnchorSegment("KEEP")),
},
LineItem::seg(Cow::Borrowed("anchor"), Box::new(AnchorSegment("KEEP"))),
];
// Full: 18 + 1 + 4 = 23. Budget 17 → overflow 6 → target 12.
// Compact 10 cells fits → shrink applied; line = "longbranch KEEP" (15).
Expand Down Expand Up @@ -1644,15 +1641,12 @@ fn emit_reflow_applied_when_truncatable_segment_end_ellipsis_fits() {
}
}
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("reflowed"),
segment: Box::new(TruncatableStub("workspace-very-long-name")),
},
LineItem::seg(
Cow::Borrowed("reflowed"),
Box::new(TruncatableStub("workspace-very-long-name")),
),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("anchor"),
segment: Box::new(AnchorSegment("X")),
},
LineItem::seg(Cow::Borrowed("anchor"), Box::new(AnchorSegment("X"))),
];
// Full: 24 + 1 + 1 = 26. Budget 10 → overflow 16 → target 8.
// try_reflow truncates to 8 cells ("workspa…" or similar).
Expand Down Expand Up @@ -1690,10 +1684,10 @@ fn emit_width_bound_under_min_drop_when_render_below_min_floor() {
.with_width(WidthBounds::new(10, u16::MAX).expect("valid"))
}
}
let items: Vec<LineItem> = vec![LineItem::Segment {
id: Cow::Borrowed("narrow"),
segment: Box::new(NarrowSegment),
}];
let items: Vec<LineItem> = vec![LineItem::seg(
Cow::Borrowed("narrow"),
Box::new(NarrowSegment),
)];
let_capturing_observers!(observers, decisions);
let _ = render_to_runs(&items, &empty_ctx(), 100, &mut observers);
assert_eq!(decisions.len(), 1, "exactly one decision: {decisions:?}");
Expand Down Expand Up @@ -1725,10 +1719,7 @@ fn emit_width_bound_over_max_truncate_when_render_above_max() {
SegmentDefaults::with_priority(10).with_width(WidthBounds::new(0, 5).expect("valid"))
}
}
let items: Vec<LineItem> = vec![LineItem::Segment {
id: Cow::Borrowed("wide"),
segment: Box::new(WideSegment),
}];
let items: Vec<LineItem> = vec![LineItem::seg(Cow::Borrowed("wide"), Box::new(WideSegment))];
let_capturing_observers!(observers, decisions);
let _ = render_to_runs(&items, &empty_ctx(), 100, &mut observers);
assert_eq!(decisions.len(), 1, "exactly one decision: {decisions:?}");
Expand Down Expand Up @@ -1783,15 +1774,15 @@ fn emit_priority_drop_via_truncatable_path_when_reflow_target_below_floor() {
}
}
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("anchor"),
segment: Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
},
LineItem::seg(
Cow::Borrowed("anchor"),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("floor-bound"),
segment: Box::new(FloorBoundTruncatable),
},
LineItem::seg(
Cow::Borrowed("floor-bound"),
Box::new(FloorBoundTruncatable),
),
];
// Total: 1 + 1 + 10 = 12. Budget 6 → overflow 6 → target 4.
// Reflow floor max(8, 2) = 8 rejects target 4 → drop via the
Expand Down Expand Up @@ -1836,23 +1827,20 @@ fn emit_multiple_decisions_in_iteration_order_under_compound_pressure() {
}
}
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("anchor"),
segment: Box::new(AnchorSegment("A")),
},
LineItem::seg(Cow::Borrowed("anchor"), Box::new(AnchorSegment("A"))),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("shrinkable"),
segment: Box::new(ShrinkableSegment {
LineItem::seg(
Cow::Borrowed("shrinkable"),
Box::new(ShrinkableSegment {
full: "longbranch * ↑2 ↓1",
compact: "longbranch",
}),
},
),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("droppable"),
segment: Box::new(DroppableP150("midpriority")),
},
LineItem::seg(
Cow::Borrowed("droppable"),
Box::new(DroppableP150("midpriority")),
),
];
// Full: 1 + 1 + 18 + 1 + 11 = 32. Budget 11.
// Iter 1: priority 200 (shrinkable) fires. try_shrink target =
Expand Down Expand Up @@ -1912,20 +1900,11 @@ fn emit_priority_drop_does_not_panic_on_zero_width_segment() {
// Two anchors flanking an empty priority-200 segment so the
// separators on either side give the engine overflow to chase.
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("anchor-l"),
segment: Box::new(AnchorSegment("L")),
},
LineItem::seg(Cow::Borrowed("anchor-l"), Box::new(AnchorSegment("L"))),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("empty"),
segment: Box::new(EmptySegment),
},
LineItem::seg(Cow::Borrowed("empty"), Box::new(EmptySegment)),
LineItem::Separator(Separator::Space),
LineItem::Segment {
id: Cow::Borrowed("anchor-r"),
segment: Box::new(AnchorSegment("R")),
},
LineItem::seg(Cow::Borrowed("anchor-r"), Box::new(AnchorSegment("R"))),
];
// Total widths: 1 + 1 + 0 + 1 + 1 = 4. Budget 2 forces drop;
// empty (priority 200) is the only droppable target.
Expand Down
20 changes: 19 additions & 1 deletion crates/linesmith-core/src/segments/builder/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ fn interleave_separators(
// n=0 saturates to 0; n>=1 gives 2n-1 slots (n segments + n-1 separators).
let mut items = Vec::with_capacity(n.saturating_mul(2).saturating_sub(1));
for (i, (id, segment)) in segs.into_iter().enumerate() {
items.push(LineItem::Segment { id, segment });
items.push(LineItem::Segment {
id,
segment,
fuses_left: false,
});
if i + 1 < n {
items.push(LineItem::Separator(sep.clone()));
}
Expand Down Expand Up @@ -274,12 +278,22 @@ fn build_one_line(
// explicit separator entry (so `seg(merge), |, seg` drops the
// explicit separator AND the implicit interleave).
let mut merge_pending = false;
// True when the most-recently-pushed segment opted to fuse its
// right neighbor into one color group (ADR-0029). Orthogonal to
// `merge_pending`: it never affects separators, only the next
// segment's `fuses_left`. Persists across an explicit separator so
// `seg(group), <space>, seg` fuses while keeping the space.
let mut group_pending = false;

for entry in entries {
if matches!(entry, config::LineEntry::Item(item) if item.kind.as_deref() == Some("separator") && item.merge.is_some())
{
warn("[line].segments separator entry has `merge = ...`; ignoring (merge is for segment entries)");
}
if matches!(entry, config::LineEntry::Item(item) if item.kind.as_deref() == Some("separator") && item.group.is_some())
{
warn("[line].segments separator entry has `group = ...`; ignoring (group is for segment entries)");
}
if matches!(entry, config::LineEntry::Item(item) if item.kind.as_deref() != Some("separator") && item.character.is_some())
{
warn("[line].segments segment entry has `character = ...`; ignoring (character is for separator entries)");
Expand Down Expand Up @@ -367,8 +381,12 @@ fn build_one_line(
items.push(LineItem::Segment {
id: resolve_segment_id(id),
segment: seg,
fuses_left: group_pending,
});
merge_pending = entry.merge();
// `merge = true` implies grouping unless `group = false`
// overrides — an abutted pair reads as one visual unit.
group_pending = entry.group().unwrap_or(merge_pending);
}
}
}
Expand Down
Loading
Loading