From dcfa1029c31962d97f8c78298d937b241c629683 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 22 May 2026 13:37:55 -0400 Subject: [PATCH] feat(jetbrains): add Align wrapper with TRACK mode, replace CenterShrinkPanel --- packages/kilo-jetbrains/AGENTS.md | 38 ++ .../client/session/ui/EmptySessionPanel.kt | 9 +- .../kotlin/ai/kilocode/client/ui/Align.kt | 152 +++++++ .../kilocode/client/ui/CenterShrinkPanel.kt | 38 -- .../kotlin/ai/kilocode/client/ui/AlignTest.kt | 396 ++++++++++++++++++ 5 files changed, 591 insertions(+), 42 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 13112339cd5..c3e6df981c7 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -366,6 +366,44 @@ For common spacing lookups, prefer `JBUI.CurrentTheme` area-specific insets (e.g | Simple `BorderLayout` panels | `JBUI.Panels.simplePanel(...)`, `BorderLayoutPanel` | | Simple vertical custom Swing groups | `VerticalLayout` | | Fluent platform panels | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()`, `.withBackground(...)` | +| Single-component alignment wrapper | `ai.kilocode.client.ui.Align` — see section below | + +### Align — Single-Component Alignment Wrapper + +Use `Align` (`ai.kilocode.client.ui.Align`) when a single Swing component must be positioned inside available space without adding visual chrome. It is a transparent, no-border, no-color `JPanel(null)` that lays out its one child according to independent horizontal (`HAlign`) and vertical (`VAlign`) modes. `CenterShrinkPanel` has been removed; use `child.alignCenter()` as a direct replacement. + +**Alignment modes:** + +| Mode | Axis | Layout behavior | Wrapper size contribution | +|---|---|---|---| +| `HAlign.TRACK` / `VAlign.TRACK` | either | Child always fills all available space; ignores child min/preferred/max | Zero (wrapper reports insets only on that axis) | +| `HAlign.FIT` / `VAlign.FIT` | either | Child fills available space clamped to child's effective `[min, max]` range | Child min/preferred/max respected | +| `HAlign.LEFT` / `VAlign.TOP` | H / V | Child placed at left/top edge at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected | +| `HAlign.CENTER` / `VAlign.CENTER` | H / V | Child centered at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected | +| `HAlign.RIGHT` / `VAlign.BOTTOM` | H / V | Child placed at right/bottom edge at bounded preferred size; shrinks to available when necessary | Child min/preferred/max respected | + +"Bounded preferred" means the child's preferred size coerced into the effective `[min, max]` range. If available space is smaller than the effective minimum, the layout shrinks the child to available space to avoid overflow. + +**Kotlin-style factory extensions** on `Component`: + +```kotlin +child.align(HAlign.LEFT, VAlign.TOP) // explicit modes +child.alignCenter() // CENTER / CENTER (replaces CenterShrinkPanel) +child.alignLeft(VAlign.CENTER) // LEFT + custom V +child.alignRight(VAlign.CENTER) // RIGHT + custom V +child.alignTop(HAlign.CENTER) // TOP + custom H +child.alignBottom() // BOTTOM + FIT horizontal +child.track() // TRACK / TRACK — always fills all space +child.trackX(VAlign.TOP) // TRACK horizontal, TOP vertical +child.trackY(HAlign.CENTER) // CENTER horizontal, TRACK vertical +``` + +**Rules:** + +- Prefer the factory extensions over creating one-off `JPanel(FlowLayout(...))` or `BorderLayoutPanel` wrappers just to control alignment. +- Use `TRACK` when the child must occupy all available space on an axis and must not reserve any space in the parent's size negotiation on that axis. Use `FIT` when you want to fill available space but still respect child min/max constraints. +- All non-TRACK modes include the child's min, preferred, and max sizes in the wrapper's own min/preferred/max size. This means parent layout managers see the child constraints through the wrapper. +- Do not use `Align` for spacing, padding, borders, colors, or multi-child layout — use `JBUI.Borders.empty(...)`, `UiStyle.Gap`, or an appropriate layout manager for those concerns. ### IntelliJ UI Surfaces diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 5c726fc0ac1..6f83dc6bc51 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -10,8 +10,9 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionController -import ai.kilocode.client.ui.CenterShrinkPanel +import ai.kilocode.client.ui.Align import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.alignCenter import ai.kilocode.rpc.dto.SessionDto import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable @@ -44,7 +45,7 @@ import javax.swing.ListSelectionModel * Empty-session panel. * * The content is a BorderLayout panel, wrapped in a - * [CenterShrinkPanel] (exposed as [view]) so callers need not know about centering. + * [Align] (exposed as [view]) so callers need not know about centering. */ class EmptySessionPanel( parent: Disposable, @@ -52,7 +53,7 @@ class EmptySessionPanel( recents: List, private val history: () -> Unit = {}, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { - val view: CenterShrinkPanel = CenterShrinkPanel(this) + val view: Align = alignCenter() private val model = DefaultListModel() private var hover = -1 @@ -133,7 +134,7 @@ class EmptySessionPanel( val header = BorderLayoutPanel(0, gap).apply { isOpaque = false add(logo, BorderLayout.NORTH) - add(CenterShrinkPanel(description), BorderLayout.CENTER) + add(description.alignCenter(), BorderLayout.CENTER) } val recent = BorderLayoutPanel().apply { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt new file mode 100644 index 00000000000..c1669e8876f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/Align.kt @@ -0,0 +1,152 @@ +package ai.kilocode.client.ui + +import java.awt.Component +import java.awt.Dimension +import javax.swing.JPanel + +enum class HAlign { TRACK, FIT, LEFT, CENTER, RIGHT } +enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM } + +/** + * A transparent wrapper panel that positions its single child according to independent + * horizontal ([h]) and vertical ([v]) alignment modes. + * + * **TRACK**: child fills all available space on that axis, ignoring child min/preferred/max. + * The wrapper reports zero contribution from the child on that axis for its own min/preferred/max. + * + * **FIT**: child fills available space clamped to child's effective [min, max] range. + * + * **LEFT / CENTER / RIGHT** (horizontal) and **TOP / CENTER / BOTTOM** (vertical): + * child uses its bounded preferred size (coerced into [min, max]) and is placed at the + * corresponding edge or centered. Shrinks to available space when necessary. + * + * Wrapper min/preferred/max sizes are computed by combining the per-axis child contribution + * (zero for TRACK axes) with the panel insets. + * + * Use the Kotlin-style factory extensions for concise call sites: + * ``` + * label.alignCenter() + * button.alignRight(VAlign.CENTER) + * panel.align(HAlign.LEFT, VAlign.TOP) + * content.track() + * scrollable.trackX(VAlign.TOP) + * ``` + */ +class Align( + child: Component, + private val h: HAlign = HAlign.FIT, + private val v: VAlign = VAlign.FIT, +) : JPanel(null) { + + init { + isOpaque = false + add(child) + } + + // ----------------------------------------------------------------------- + // Layout + // ----------------------------------------------------------------------- + + override fun doLayout() { + if (componentCount == 0) return + val child = getComponent(0) + val ins = insets + val availW = maxOf(0, width - ins.left - ins.right) + val availH = maxOf(0, height - ins.top - ins.bottom) + + val (w, cx) = placeAxis(h, availW, child.minimumSize.width, child.preferredSize.width, child.maximumSize.width) + val (ht, cy) = placeAxis(v, availH, child.minimumSize.height, child.preferredSize.height, child.maximumSize.height) + + child.setBounds(ins.left + cx, ins.top + cy, w, ht) + } + + // ----------------------------------------------------------------------- + // Wrapper size negotiation + // ----------------------------------------------------------------------- + + override fun getMinimumSize(): Dimension { + if (componentCount == 0) return super.getMinimumSize() + val child = getComponent(0) + val ins = insets + val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width + val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height + return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) + } + + override fun getPreferredSize(): Dimension { + if (componentCount == 0) return super.getPreferredSize() + val child = getComponent(0) + val ins = insets + val cw = if (h == HAlign.TRACK) 0 else bounded(child.preferredSize.width, child.minimumSize.width, child.maximumSize.width) + val ch = if (v == VAlign.TRACK) 0 else bounded(child.preferredSize.height, child.minimumSize.height, child.maximumSize.height) + return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) + } + + override fun getMaximumSize(): Dimension { + if (componentCount == 0) return super.getMaximumSize() + val child = getComponent(0) + val ins = insets + val cw = if (h == HAlign.TRACK) super.getMaximumSize().width else maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right + val ch = if (v == VAlign.TRACK) super.getMaximumSize().height else maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom + return Dimension(cw, ch) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Returns (size, offset) for a single axis. Offset is relative to the inner origin (after insets). + * - TRACK: size = avail, offset = 0 + * - FIT: size = clamp(avail, min, max), offset = 0 + * - edge/center: size = clamp(boundedPref, 0, avail), offset positions according to alignment + */ +private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair { + val effMax = maxOf(min, max) + return when (mode) { + HAlign.TRACK, VAlign.TRACK -> avail to 0 + HAlign.FIT, VAlign.FIT -> { + // fill available, capped at effMax; if avail < min we still shrink to avail + val size = minOf(avail, effMax) + size to 0 + } + HAlign.LEFT, VAlign.TOP -> { + val size = minOf(bounded(pref, min, effMax), avail) + size to 0 + } + HAlign.CENTER, VAlign.CENTER -> { + val size = minOf(bounded(pref, min, effMax), avail) + size to (avail - size) / 2 + } + HAlign.RIGHT, VAlign.BOTTOM -> { + val size = minOf(bounded(pref, min, effMax), avail) + size to (avail - size) + } + else -> avail to 0 + } +} + +private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max)) + +// --------------------------------------------------------------------------- +// Kotlin-style factory extensions +// --------------------------------------------------------------------------- + +fun Component.align(h: HAlign = HAlign.FIT, v: VAlign = VAlign.FIT) = Align(this, h, v) + +fun Component.alignCenter() = Align(this, HAlign.CENTER, VAlign.CENTER) + +fun Component.alignLeft(v: VAlign = VAlign.FIT) = Align(this, HAlign.LEFT, v) + +fun Component.alignRight(v: VAlign = VAlign.FIT) = Align(this, HAlign.RIGHT, v) + +fun Component.alignTop(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.TOP) + +fun Component.alignBottom(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.BOTTOM) + +fun Component.track() = Align(this, HAlign.TRACK, VAlign.TRACK) + +fun Component.trackX(v: VAlign = VAlign.FIT) = Align(this, HAlign.TRACK, v) + +fun Component.trackY(h: HAlign = HAlign.FIT) = Align(this, h, VAlign.TRACK) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt deleted file mode 100644 index 57e7d95e5f4..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ai.kilocode.client.ui - -import java.awt.Component -import java.awt.Dimension -import javax.swing.JPanel - -/** - * Centers its single child and shrinks it to available space when needed. - * If available space is larger than the child's maximum size, the child is not expanded. - */ -class CenterShrinkPanel(child: Component) : JPanel(null) { - init { - isOpaque = false - add(child) - } - - override fun doLayout() { - if (componentCount == 0) return - val child = getComponent(0) - val insets = getInsets() - val availW = width - insets.left - insets.right - val availH = height - insets.top - insets.bottom - val pref = child.preferredSize - val max = child.maximumSize - val w = minOf(pref.width, max.width, availW) - val h = minOf(pref.height, max.height, availH) - val x = insets.left + (availW - w) / 2 - val y = insets.top + (availH - h) / 2 - child.setBounds(x, y, w, h) - } - - override fun getPreferredSize(): Dimension { - if (componentCount == 0) return super.getPreferredSize() - val pref = getComponent(0).preferredSize - val insets = getInsets() - return Dimension(pref.width + insets.left + insets.right, pref.height + insets.top + insets.bottom) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt new file mode 100644 index 00000000000..841e9f237e2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/AlignTest.kt @@ -0,0 +1,396 @@ +package ai.kilocode.client.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.Dimension + +@Suppress("UnstableApiUsage") +class AlignTest : BasePlatformTestCase() { + + // ------ structure ------ + + fun `test wrapper is non-opaque`() { + assertFalse(Align(JBLabel("x"), HAlign.FIT, VAlign.FIT).isOpaque) + } + + fun `test wrapper contains exactly the wrapped child`() { + val child = JBLabel("x") + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + assertEquals(1, wrap.componentCount) + assertSame(child, wrap.getComponent(0)) + } + + // ------ FIT / FIT basic fill ------ + + fun `test FIT FIT fills assigned inner bounds`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 100, child) + } + + fun `test FIT FIT respects insets`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.border = JBUI.Borders.empty(5, 10, 5, 10) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(10, 5, 180, 90, child) + } + + // ------ FIT respects max ------ + + fun `test FIT FIT caps at maximum size`() { + val child = child(pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // available > max → capped at max, placed at top-left + assertBounds(0, 0, 60, 30, child) + } + + fun `test FIT FIT expands to minimum when available between min and pref`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 200 x 100) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 50, 25) + wrap.doLayout() + // available (50x25) is within [min, max], so child gets exactly available + assertBounds(0, 0, 50, 25, child) + } + + fun `test FIT FIT shrinks to available when available below minimum`() { + val child = child(min = 80 x 40, pref = 80 x 40) + val wrap = Align(child, HAlign.FIT, VAlign.FIT) + wrap.setBounds(0, 0, 30, 10) + wrap.doLayout() + // cannot respect min when space is smaller + assertBounds(0, 0, 30, 10, child) + } + + // ------ CENTER / CENTER ------ + + fun `test CENTER CENTER centers at preferred size when space sufficient`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 40, 40, 20, child) + } + + fun `test CENTER CENTER coerces preferred up to minimum`() { + val child = child(min = 60 x 30, pref = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // preferred < min → use min (60x30), centered + assertBounds(70, 35, 60, 30, child) + } + + fun `test CENTER CENTER caps preferred at maximum`() { + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // preferred > max → use max (40x20), centered + assertBounds(80, 40, 40, 20, child) + } + + fun `test CENTER CENTER fits when bounded preferred exceeds available`() { + val child = child(pref = 300 x 200) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + assertBounds(0, 0, 100, 80, child) + } + + fun `test CENTER CENTER shrinks to available when available below minimum`() { + val child = child(min = 150 x 90, pref = 150 x 90) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 100, 60) + wrap.doLayout() + assertBounds(0, 0, 100, 60, child) + } + + // ------ LEFT / TOP ------ + + fun `test LEFT TOP positions at top-left with bounded preferred`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 40, 20, child) + } + + fun `test LEFT TOP respects max`() { + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 40, 20, child) + } + + fun `test LEFT TOP shrinks to available`() { + val child = child(pref = 300 x 200) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + assertBounds(0, 0, 100, 80, child) + } + + // ------ RIGHT / BOTTOM ------ + + fun `test RIGHT BOTTOM positions at bottom-right with bounded preferred`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(160, 80, 40, 20, child) + } + + fun `test RIGHT BOTTOM respects max`() { + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(160, 80, 40, 20, child) + } + + fun `test RIGHT BOTTOM shrinks to available`() { + val child = child(pref = 300 x 200) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + assertBounds(0, 0, 100, 80, child) + } + + // ------ insets with edge modes ------ + + fun `test CENTER CENTER insets honored`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.border = JBUI.Borders.empty(10, 20, 10, 20) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + val ins = wrap.insets // 10,20,10,20 + // inner: 160x80; child 40x20 + assertBounds(ins.left + 60, ins.top + 30, 40, 20, child) + } + + fun `test RIGHT BOTTOM insets honored`() { + val child = child(pref = 40 x 20) + val wrap = Align(child, HAlign.RIGHT, VAlign.BOTTOM) + wrap.border = JBUI.Borders.empty(5, 5, 5, 5) + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + val ins = wrap.insets + // inner: 90x70; child 40x20 + assertBounds(ins.left + 50, ins.top + 50, 40, 20, child) + } + + // ------ wrapper preferred/min/max sizes (non-TRACK) ------ + + fun `test preferredSize equals bounded child pref plus insets`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + // pref(80) coerced into [30,60] = 60; pref(40) coerced into [15,30] = 30 + val ps = wrap.preferredSize + assertEquals(60 + ins.left + ins.right, ps.width) + assertEquals(30 + ins.top + ins.bottom, ps.height) + } + + fun `test minimumSize equals child min plus insets`() { + val child = child(min = 30 x 15, pref = 80 x 40) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + val ms = wrap.minimumSize + assertEquals(30 + ins.left + ins.right, ms.width) + assertEquals(15 + ins.top + ins.bottom, ms.height) + } + + fun `test maximumSize equals effective child max plus insets`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + val xs = wrap.maximumSize + assertEquals(60 + ins.left + ins.right, xs.width) + assertEquals(30 + ins.top + ins.bottom, xs.height) + } + + fun `test maximumSize uses min when max is smaller than min`() { + // max < min → effective max should be at least min + val child = child(min = 50 x 30, pref = 50 x 30, max = 10 x 5) + val wrap = Align(child, HAlign.LEFT, VAlign.TOP) + val ins = wrap.insets + val xs = wrap.maximumSize + assertEquals(50 + ins.left + ins.right, xs.width) + assertEquals(30 + ins.top + ins.bottom, xs.height) + } + + // ------ CenterShrinkPanel parity ------ + + fun `test CENTER CENTER matches old CenterShrinkPanel center-and-shrink behavior`() { + // child pref is larger than max → should center at max size, not overflow + val child = child(pref = 100 x 60, max = 40 x 20) + val wrap = Align(child, HAlign.CENTER, VAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // expected: max(40x20), centered → x=(200-40)/2=80, y=(100-20)/2=40 + assertBounds(80, 40, 40, 20, child) + } + + // ------ TRACK / TRACK ------ + + fun `test TRACK TRACK fills all available regardless of child constraints`() { + val child = child(min = 10 x 5, pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.TRACK) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 100, child) + } + + fun `test TRACK TRACK preferred and min size are just insets`() { + val child = child(min = 50 x 30, pref = 80 x 40, max = 100 x 60) + val wrap = Align(child, HAlign.TRACK, VAlign.TRACK) + wrap.border = JBUI.Borders.empty(4, 6, 4, 6) + val ins = wrap.insets + val ps = wrap.preferredSize + val ms = wrap.minimumSize + assertEquals(ins.left + ins.right, ps.width) + assertEquals(ins.top + ins.bottom, ps.height) + assertEquals(ins.left + ins.right, ms.width) + assertEquals(ins.top + ins.bottom, ms.height) + } + + fun `test TRACK TRACK max size is not capped by child max`() { + val child = child(pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.TRACK) + val xs = wrap.maximumSize + // wrapper max must be larger than child max since TRACK should allow any size + assertTrue("wrapper maxW ${xs.width} should exceed child maxW 60", xs.width > 60) + assertTrue("wrapper maxH ${xs.height} should exceed child maxH 30", xs.height > 30) + } + + // ------ mixed TRACK + non-TRACK ------ + + fun `test TRACK H FIT V fills width ignores child constraints on H only`() { + val child = child(min = 30 x 15, pref = 40 x 20, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.FIT) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + // H=TRACK → width=200; V=FIT → height clamped to [15,30]=30 + assertBounds(0, 0, 200, 30, child) + } + + fun `test TRACK H preferred is inset-only on H axis with child bounded pref on V axis`() { + val child = child(min = 30 x 15, pref = 80 x 40, max = 60 x 30) + val wrap = Align(child, HAlign.TRACK, VAlign.CENTER) + val ins = wrap.insets + val ps = wrap.preferredSize + // H=TRACK → horizontal contribution = 0 + assertEquals(ins.left + ins.right, ps.width) + // V=CENTER → bounded pref height = clamp(40,[15,30]) = 30 + assertEquals(30 + ins.top + ins.bottom, ps.height) + } + + // ------ factory helpers ------ + + fun `test align extension returns Align wrapping child`() { + val child = JBLabel("x") + assertSame(child, child.align(HAlign.LEFT, VAlign.TOP).getComponent(0)) + } + + fun `test alignCenter produces CENTER CENTER`() { + val child = child(pref = 40 x 20) + val wrap = child.alignCenter() + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 40, 40, 20, child) + } + + fun `test alignRight produces RIGHT with given VAlign`() { + val child = child(pref = 40 x 20) + val wrap = child.alignRight(VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(160, 0, 40, 20, child) + } + + fun `test alignLeft with default FIT vertical fills height`() { + val child = child(pref = 40 x 20) + val wrap = child.alignLeft() + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 40, 100, child) + } + + fun `test alignTop with CENTER horizontal centers and pins to top`() { + val child = child(pref = 40 x 20) + val wrap = child.alignTop(HAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 0, 40, 20, child) + } + + fun `test alignBottom with default FIT horizontal fills width and pins to bottom`() { + val child = child(pref = 40 x 20) + val wrap = child.alignBottom() + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 80, 200, 20, child) + } + + fun `test track fills all space and wrapper preferred is inset-only`() { + val child = child(pref = 40 x 20, max = 60 x 30) + val wrap = child.track() + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 100, child) + val ins = wrap.insets + assertEquals(ins.left + ins.right, wrap.preferredSize.width) + assertEquals(ins.top + ins.bottom, wrap.preferredSize.height) + } + + fun `test trackX fills width only, V respects preferred`() { + val child = child(pref = 40 x 20) + val wrap = child.trackX(VAlign.TOP) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(0, 0, 200, 20, child) + } + + fun `test trackY fills height only, H respects preferred`() { + val child = child(pref = 40 x 20) + val wrap = child.trackY(HAlign.CENTER) + wrap.setBounds(0, 0, 200, 100) + wrap.doLayout() + assertBounds(80, 0, 40, 100, child) + } + + // ------ helpers ------ + + private infix fun Int.x(h: Int) = Dimension(this, h) + + private fun child( + min: Dimension = Dimension(0, 0), + pref: Dimension, + max: Dimension = Dimension(Int.MAX_VALUE, Int.MAX_VALUE), + ) = object : JBLabel("x") { + override fun getMinimumSize() = min + override fun getPreferredSize() = pref + override fun getMaximumSize() = max + } + + private fun assertBounds(x: Int, y: Int, w: Int, h: Int, c: java.awt.Component) { + val b = c.bounds + assertEquals("x", x, b.x) + assertEquals("y", y, b.y) + assertEquals("width", w, b.width) + assertEquals("height", h, b.height) + } +}