From e4b610a4fcf270fe2bdae929ad0645770bcab2fc Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 24 May 2026 20:54:46 -0400 Subject: [PATCH] feat(jetbrains): add stack layout helper --- packages/kilo-jetbrains/AGENTS.md | 39 ++- .../ai/kilocode/client/session/SessionUi.kt | 8 +- .../client/session/ui/ProgressPanel.kt | 10 +- .../ui/account/SessionAccountOverlay.kt | 17 +- .../views/permission/PermissionView.kt | 11 +- .../settings/KiloSettingsConfigurable.kt | 10 +- .../ai/kilocode/client/ui/layout/Align.kt | 33 +- .../ai/kilocode/client/ui/layout/Stack.kt | 192 +++++++++++ .../ai/kilocode/client/ui/layout/AlignTest.kt | 28 ++ .../ai/kilocode/client/ui/layout/StackTest.kt | 301 ++++++++++++++++++ 10 files changed, 606 insertions(+), 43 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 8afdd0ecac3..41ee28808c3 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -364,10 +364,47 @@ For common spacing lookups, prefer `JBUI.CurrentTheme` area-specific insets (e.g | Side separators | `JBUI.Borders.customLineTop(...)`, `customLineBottom(...)` | | Composed borders | `JBUI.Borders.compound(...)`, `JBUI.Borders.merge(...)` | | Simple `BorderLayout` panels | `JBUI.Panels.simplePanel(...)`, `BorderLayoutPanel` | -| Simple vertical custom Swing groups | `VerticalLayout` | +| One-dimensional multi-component rows/columns | `ai.kilocode.client.ui.layout.Stack` — see section below | | Fluent platform panels | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()`, `.withBackground(...)` | | Single-component alignment wrapper | `ai.kilocode.client.ui.layout.Align` — see section below | +### Stack — One-Dimensional Multi-Component Layout + +Use `Stack` (`ai.kilocode.client.ui.layout.Stack`) when multiple Swing components should be laid out as one vertical column or one horizontal row without visual chrome. It is a transparent, no-border, no-color `JPanel(null)` that lays out visible children in insertion order. + +**Behavior:** + +| Mode | Layout behavior | Size contribution | +|---|---|---| +| `Stack.vertical(gap)` | Children are placed top-to-bottom; each child fills the available container width; each child keeps its bounded preferred height | Width is max child width; height is summed child heights plus gaps | +| `Stack.horizontal(gap)` | Children are placed left-to-right; each child fills the available container height; each child keeps its bounded preferred width | Width is summed child widths plus gaps; height is max child height | + +"Bounded preferred" means the child's preferred size on the stack axis is coerced into the effective `[min, max]` range. On the cross axis, layout tracks the container size even if that ignores an individual child's preferred/minimum/maximum size. + +**Factories and fluent additions:** + +```kotlin +Stack.vertical() + .next(header) + .next(body) + +Stack.horizontal(gap = UiStyle.Gap.md()) + .next(icon) + .next(label) + +Stack.vertical(gap = UiStyle.Gap.sm()) + .next(summary) + .gap(UiStyle.Gap.lg()) + .next(details) +``` + +**Rules:** + +- Prefer `Stack.vertical(...)` or `Stack.horizontal(...)` over one-off `JPanel` + `BoxLayout` or simple single-line `FlowLayout` rows/columns. +- Use the constructor `gap` for the normal spacing between adjacent visible children. Use `gap(size)` for an explicit one-off gap before the next visible child. +- Use `Stack` for simple retained Swing rows/columns where children should track the cross-axis size. Use `Align` for positioning one child inside available space. +- Do not use `Stack` for padding, borders, colors, wrapping rows, flexible glue/spacers, or transcript components that need width-aware HTML reflow. Use `JBUI.Borders.empty(...)`, `UiStyle.Gap`, purpose-built layouts, or `SessionLayout` for those concerns. + ### Align — Single-Component Alignment Wrapper Use `Align` (`ai.kilocode.client.ui.layout.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.align(HAlign.CENTER, VAlign.CENTER)` as a direct replacement. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 16f3fda3444..8f59e910c98 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -31,6 +31,7 @@ import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.log.ChatLogSummary import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog @@ -52,7 +53,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout -import javax.swing.BoxLayout import javax.swing.JComponent import javax.swing.JPanel @@ -227,11 +227,7 @@ class SessionUi( sessionContent.add(header, BorderLayout.NORTH) sessionContent.add(scroll.component, BorderLayout.CENTER) root.content.add(sessionContent, BorderLayout.CENTER) - root.content.add(JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(connection) - add(prompt) - }, BorderLayout.SOUTH) + root.content.add(Stack.vertical().next(connection).next(prompt), BorderLayout.SOUTH) add(root, BorderLayout.CENTER) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 5454737bc6c..3c2dd20b8c5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -6,10 +6,11 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel -import java.awt.FlowLayout /** * Progress footer rendered at the bottom of the session transcript while the @@ -25,7 +26,7 @@ import java.awt.FlowLayout class ProgressPanel( model: SessionModel, parent: Disposable, -) : SessionLayoutPanel(), SessionEditorStyleTarget { +) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()), SessionEditorStyleTarget { private val label = JBLabel().apply { foreground = UiStyle.Colors.weak() @@ -34,11 +35,10 @@ class ProgressPanel( init { isOpaque = false isVisible = false - layout = FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0) applyStyle(SessionEditorStyle.current()) - add(JBLabel(AnimatedIcon.Default())) - add(label) + next(JBLabel(AnimatedIcon.Default())) + next(label) model.addListener(parent) { event -> if (event is SessionModelEvent.StateChanged) onState(event.state) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt index d30d3ca8803..c9764e16de6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.PickerButton import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.CollectionListModel @@ -24,10 +25,7 @@ import java.awt.Cursor import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.Box -import javax.swing.BoxLayout import javax.swing.JComponent -import javax.swing.JPanel import javax.swing.KeyStroke import javax.swing.ListSelectionModel import javax.swing.ScrollPaneConstants @@ -68,15 +66,10 @@ internal class SessionAccountOverlay( addActionListener { profile() } } - private val row = JPanel().apply { - layout = BoxLayout(this, BoxLayout.X_AXIS) - isOpaque = false - add(picker) - add(Box.createHorizontalStrut(UiStyle.Gap.md())) - add(balance) - add(Box.createHorizontalStrut(UiStyle.Gap.md())) - add(profileBtn) - } + private val row = Stack.horizontal(gap = UiStyle.Gap.md()) + .next(picker) + .next(balance) + .next(profileBtn) private val panel = RoundedContentPanel(UiStyle.Gap.lg(), UiStyle.Gap.lg()).apply { addToCenter(row) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index 886cf788e4e..3ffcf12fe84 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionUiStyle.View.CARD_LAYOUT_GAP import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.rpc.dto.PermissionReplyDto @@ -25,9 +26,7 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout -import java.awt.Component import java.awt.FlowLayout -import javax.swing.BoxLayout import javax.swing.JPanel import javax.swing.text.html.StyleSheet @@ -48,11 +47,7 @@ class PermissionView( private val card = BaseQuestionView() - private val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = Component.LEFT_ALIGNMENT - } + private val body = Stack.vertical() // Track target panes for style updates private val panes = mutableListOf() @@ -125,7 +120,6 @@ class PermissionView( private fun addDetailRow(action: String, target: String?, diffs: List) { val row = JPanel(BorderLayout(CARD_LAYOUT_GAP, 0)).apply { isOpaque = false - alignmentX = Component.LEFT_ALIGNMENT } val actionLbl = JBLabel(action).apply { @@ -207,7 +201,6 @@ class PermissionView( val label = JBLabel(msg).apply { border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) - alignmentX = Component.LEFT_ALIGNMENT } body.add(label) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index 586be860e38..e3775550f41 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -2,15 +2,14 @@ package ai.kilocode.client.settings import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.ui.layout.Stack import com.intellij.ide.DataManager import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.options.ex.Settings import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI -import javax.swing.BoxLayout import javax.swing.JComponent -import javax.swing.JPanel /** * Root settings entry under Settings -> Tools -> Kilo Code. @@ -31,13 +30,12 @@ class KiloSettingsConfigurable : SearchableConfigurable { override fun getDisplayName(): String = KiloBundle.message("settings.kilo.displayName") override fun createComponent(): JComponent { - val panel = JPanel() - panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) + val panel = Stack.vertical() panel.border = JBUI.Borders.empty(8, 0, 0, 0) val desc = JBLabel(KiloBundle.message("settings.kilo.description")) desc.border = JBUI.Borders.emptyBottom(12) - panel.add(desc) + panel.next(desc) val link = ActionLink(KiloBundle.message("settings.profile.displayName")) { e -> val src = e.source as? JComponent ?: return@ActionLink @@ -45,7 +43,7 @@ class KiloSettingsConfigurable : SearchableConfigurable { open(settings, UserProfileConfigurable.ID) } link.border = JBUI.Borders.emptyBottom(4) - panel.add(link) + panel.next(link) return panel } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt index e811fc8a7f8..77ca35f481b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt @@ -20,6 +20,10 @@ enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM } * 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. * + * During layout, the child is first sized to the available container space before + * preferred size is read. This mirrors Swing layouts such as [java.awt.BorderLayout] + * and lets wrapping components report a preferred height for the final width. + * * Wrapper min/preferred/max sizes are computed by combining the per-axis child contribution * (zero for TRACK axes) with the panel insets. * @@ -53,8 +57,13 @@ class Align( 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) + val min = child.minimumSize + val max = child.maximumSize + child.setSize(probe(h, availW, min.width, max.width), probe(v, availH, min.height, max.height)) + val pref = child.preferredSize + + val (w, cx) = placeAxis(h, availW, min.width, pref.width, max.width) + val (ht, cy) = placeAxis(v, availH, min.height, pref.height, max.height) child.setBounds(ins.left + cx, ins.top + cy, w, ht) } @@ -76,8 +85,19 @@ class Align( 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) + val min = child.minimumSize + val max = child.maximumSize + val availW = maxOf(0, width - ins.left - ins.right) + val availH = maxOf(0, height - ins.top - ins.bottom) + if (availW > 0 || availH > 0) { + child.setSize( + if (availW > 0) probe(h, availW, min.width, max.width) else child.width, + if (availH > 0) probe(v, availH, min.height, max.height) else child.height, + ) + } + val pref = child.preferredSize + val cw = if (h == HAlign.TRACK) 0 else bounded(pref.width, min.width, max.width) + val ch = if (v == VAlign.TRACK) 0 else bounded(pref.height, min.height, max.height) return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) } @@ -128,6 +148,11 @@ private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pai private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max)) +private fun probe(mode: Any, avail: Int, min: Int, max: Int): Int { + if (mode == HAlign.FIT || mode == VAlign.FIT) return minOf(avail, maxOf(min, max)) + return avail +} + // --------------------------------------------------------------------------- // Factory extension // --------------------------------------------------------------------------- diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt new file mode 100644 index 00000000000..33235d457c5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt @@ -0,0 +1,192 @@ +package ai.kilocode.client.ui.layout + +import java.awt.Component +import java.awt.Dimension +import javax.swing.JPanel + +enum class StackAxis { VERTICAL, HORIZONTAL } + +/** + * A transparent one-dimensional layout panel for rows and columns. + * + * Vertical stacks make every child track the available width while preserving + * each child's bounded preferred height. Horizontal stacks do the opposite. + * Children are probed with the known cross-axis size before preferred size is + * read, so wrapping components can report the preferred size for that width or + * height. + */ +open class Stack( + private val axis: StackAxis, + private val gap: Int = 0, +) : JPanel(null) { + + private val entries = mutableListOf() + + init { + isOpaque = false + } + + fun next(child: Component): Stack { + add(child) + return this + } + + fun gap(size: Int = gap): Stack { + entries.add(Entry.Gap(size)) + revalidate() + return this + } + + override fun addImpl(comp: Component, constraints: Any?, index: Int) { + super.addImpl(comp, constraints, index) + val idx = if (index < 0) entries.size else entryIndex(index) + entries.add(idx, Entry.Child(comp)) + } + + override fun remove(comp: Component) { + entries.removeAll { it is Entry.Child && it.comp == comp } + super.remove(comp) + } + + override fun remove(index: Int) { + val comp = getComponent(index) + entries.removeAll { it is Entry.Child && it.comp == comp } + super.remove(index) + } + + override fun removeAll() { + entries.clear() + super.removeAll() + } + + override fun doLayout() { + val ins = insets + val w = maxOf(0, width - ins.left - ins.right) + val h = maxOf(0, height - ins.top - ins.bottom) + var x = ins.left + var y = ins.top + var seen = false + var pending = 0 + + for (entry in entries) { + when (entry) { + is Entry.Gap -> { + if (seen) pending += entry.size + } + is Entry.Child -> { + if (entry.comp.isVisible) { + if (seen) { + val space = if (pending > 0) pending else gap + if (axis == StackAxis.VERTICAL) y += space else x += space + } + pending = 0 + seen = true + if (axis == StackAxis.VERTICAL) { + entry.comp.setSize(w, entry.comp.height.coerceAtLeast(1)) + } else { + entry.comp.setSize(entry.comp.width.coerceAtLeast(1), h) + } + val pref = entry.comp.preferredSize + val min = entry.comp.minimumSize + val max = entry.comp.maximumSize + val cw = if (axis == StackAxis.VERTICAL) w else bound(pref.width, min.width, max.width) + val ch = if (axis == StackAxis.HORIZONTAL) h else bound(pref.height, min.height, max.height) + entry.comp.setBounds(x, y, cw, ch) + if (axis == StackAxis.VERTICAL) y += ch else x += cw + } + } + } + } + } + + override fun getMinimumSize() = size(Size.MIN) + override fun getPreferredSize() = size(Size.PREF) + override fun getMaximumSize() = size(Size.MAX) + + private fun size(kind: Size): Dimension { + val ins = insets + var main = 0 + var cross = 0 + var seen = false + var pending = 0 + + for (entry in entries) { + when (entry) { + is Entry.Gap -> { + if (seen) pending = safe(pending, entry.size) + } + is Entry.Child -> { + if (entry.comp.isVisible) { + if (seen) main = safe(main, if (pending > 0) pending else gap) + pending = 0 + seen = true + val dim = dim(entry.comp, kind, crossSize()) + main = safe(main, if (axis == StackAxis.VERTICAL) dim.height else dim.width) + cross = maxOf(cross, if (axis == StackAxis.VERTICAL) dim.width else dim.height) + } + } + } + } + + val w = if (axis == StackAxis.VERTICAL) cross else main + val h = if (axis == StackAxis.VERTICAL) main else cross + return Dimension(safe(w, ins.left + ins.right), safe(h, ins.top + ins.bottom)) + } + + private fun dim(comp: Component, kind: Size, cross: Int): Dimension { + if (kind == Size.MIN) return comp.minimumSize + val min = comp.minimumSize + if (kind == Size.MAX) { + val max = comp.maximumSize + return Dimension(maxOf(min.width, max.width), maxOf(min.height, max.height)) + } + if (cross > 0) { + if (axis == StackAxis.VERTICAL) { + comp.setSize(cross, comp.height.coerceAtLeast(1)) + } else { + comp.setSize(comp.width.coerceAtLeast(1), cross) + } + } + val pref = comp.preferredSize + val max = comp.maximumSize + return Dimension(bound(pref.width, min.width, max.width), bound(pref.height, min.height, max.height)) + } + + private fun crossSize(): Int { + val ins = insets + if (axis == StackAxis.VERTICAL) return maxOf(0, width - ins.left - ins.right) + return maxOf(0, height - ins.top - ins.bottom) + } + + private fun entryIndex(index: Int): Int { + var count = 0 + for ((idx, entry) in entries.withIndex()) { + if (entry is Entry.Child) { + if (count == index) return idx + count++ + } + } + return entries.size + } + + private sealed interface Entry { + data class Child(val comp: Component) : Entry + data class Gap(val size: Int) : Entry + } + + private enum class Size { MIN, PREF, MAX } + + companion object { + fun vertical(gap: Int = 0) = Stack(StackAxis.VERTICAL, gap) + fun horizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap) + } +} + +private fun bound(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max)) + +private fun safe(a: Int, b: Int): Int { + val sum = a.toLong() + b.toLong() + if (sum > Int.MAX_VALUE) return Int.MAX_VALUE + if (sum < 0) return 0 + return sum.toInt() +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt index e12db964182..22397d6b66c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt @@ -372,6 +372,34 @@ class AlignTest : BasePlatformTestCase() { assertBounds(80, 0, 40, 100, child) } + fun `test layout measures preferred height after width probe`() { + val child = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(20, if (width == 100) 12 else 60) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val wrap = child.align(HAlign.TRACK, VAlign.TOP) + + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + + assertBounds(0, 0, 100, 12, child) + } + + fun `test layout measures preferred width after height probe`() { + val child = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(if (height == 80) 17 else 70, 20) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val wrap = child.align(HAlign.LEFT, VAlign.TRACK) + + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + + assertBounds(0, 0, 17, 80, child) + } + // ------ helpers ------ private infix fun Int.x(h: Int) = Dimension(this, h) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt new file mode 100644 index 00000000000..15d66f2c9a5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt @@ -0,0 +1,301 @@ +package ai.kilocode.client.ui.layout + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.Component +import java.awt.Dimension + +@Suppress("UnstableApiUsage") +class StackTest : BasePlatformTestCase() { + + fun `test vertical stack is non-opaque`() { + assertFalse(Stack.vertical().isOpaque) + } + + fun `test horizontal stack is non-opaque`() { + assertFalse(Stack.horizontal().isOpaque) + } + + fun `test next adds direct children in order and returns stack`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical() + + assertSame(stack, stack.next(a)) + stack.next(b) + + assertEquals(2, stack.componentCount) + assertSame(a, stack.getComponent(0)) + assertSame(b, stack.getComponent(1)) + } + + fun `test vertical stacks children with default gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 8, 100, 7, b) + } + + fun `test vertical skips invisible child and its gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7).apply { isVisible = false } + val c = child(pref = 30 x 9) + val stack = Stack.vertical(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 8, 100, 9, c) + } + + fun `test vertical explicit gap overrides default next gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical(gap = 3).apply { + next(a) + gap(11) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 16, 100, 7, b) + } + + fun `test vertical fills width ignoring child width constraints`() { + val a = child(min = 30 x 4, pref = 40 x 5, max = 50 x 6) + val stack = Stack.vertical().apply { next(a) } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + } + + fun `test vertical bounds child preferred height`() { + val a = child(min = 10 x 8, pref = 20 x 3, max = 30 x 12) + val b = child(min = 10 x 2, pref = 20 x 20, max = 30 x 7) + val stack = Stack.vertical().apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 8, a) + assertBounds(0, 8, 100, 7, b) + } + + fun `test vertical respects insets`() { + val a = child(pref = 10 x 5) + val stack = Stack.vertical().apply { + border = JBUI.Borders.empty(2, 3, 4, 5) + next(a) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(3, 2, 92, 5, a) + } + + fun `test horizontal stacks children with default gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.horizontal(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 20, 50, b) + } + + fun `test horizontal skips invisible child and its gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7).apply { isVisible = false } + val c = child(pref = 30 x 9) + val stack = Stack.horizontal(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 30, 50, c) + } + + fun `test horizontal fills height ignoring child height constraints`() { + val a = child(min = 4 x 10, pref = 5 x 20, max = 6 x 30) + val stack = Stack.horizontal().apply { next(a) } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 5, 50, a) + } + + fun `test horizontal bounds child preferred width`() { + val a = child(min = 8 x 10, pref = 3 x 20, max = 12 x 30) + val b = child(min = 2 x 10, pref = 20 x 20, max = 7 x 30) + val stack = Stack.horizontal().apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + assertBounds(0, 0, 8, 50, a) + assertBounds(8, 0, 7, 50, b) + } + + fun `test horizontal respects insets`() { + val a = child(pref = 10 x 5) + val stack = Stack.horizontal().apply { + border = JBUI.Borders.empty(2, 3, 4, 5) + next(a) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(3, 2, 10, 44, a) + } + + fun `test vertical measures preferred height after width probe`() { + val a = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(20, if (width == 100) 12 else 60) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val stack = Stack.vertical().apply { next(a) } + + stack.setBounds(0, 0, 100, 80) + stack.doLayout() + + assertBounds(0, 0, 100, 12, a) + } + + fun `test horizontal measures preferred width after height probe`() { + val a = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(if (height == 80) 17 else 70, 20) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val stack = Stack.horizontal().apply { next(a) } + + stack.setBounds(0, 0, 100, 80) + stack.doLayout() + + assertBounds(0, 0, 17, 80, a) + } + + fun `test vertical preferred size sums height and maxes width`() { + val a = child(min = 5 x 2, pref = 10 x 4, max = 20 x 8) + val b = child(min = 6 x 3, pref = 30 x 5, max = 25 x 9) + val stack = Stack.vertical(gap = 7).apply { + border = JBUI.Borders.empty(1, 2, 3, 4) + next(a) + next(b) + } + + val size = stack.preferredSize + + assertEquals(25 + 2 + 4, size.width) + assertEquals(4 + 7 + 5 + 1 + 3, size.height) + } + + fun `test horizontal preferred size sums width and maxes height`() { + val a = child(min = 5 x 2, pref = 10 x 4, max = 20 x 8) + val b = child(min = 6 x 3, pref = 30 x 5, max = 25 x 9) + val stack = Stack.horizontal(gap = 7).apply { + border = JBUI.Borders.empty(1, 2, 3, 4) + next(a) + next(b) + } + + val size = stack.preferredSize + + assertEquals(10 + 7 + 25 + 2 + 4, size.width) + assertEquals(5 + 1 + 3, size.height) + } + + fun `test minimum size uses child minimum sizes`() { + val a = child(min = 5 x 2, pref = 10 x 4) + val b = child(min = 6 x 3, pref = 30 x 5) + val vertical = Stack.vertical(gap = 7).apply { + next(a) + next(b) + } + val c = child(min = 5 x 2, pref = 10 x 4) + val d = child(min = 6 x 3, pref = 30 x 5) + val horizontal = Stack.horizontal(gap = 7).apply { + next(c) + next(d) + } + + assertEquals(6 x 12, vertical.minimumSize) + assertEquals(18 x 3, horizontal.minimumSize) + } + + fun `test maximum size uses effective maximum sizes`() { + val a = child(min = 5 x 6, pref = 10 x 7, max = 1 x 2) + val b = child(min = 6 x 3, pref = 30 x 5, max = 20 x 9) + val vertical = Stack.vertical(gap = 7).apply { + next(a) + next(b) + } + val c = child(min = 5 x 6, pref = 10 x 7, max = 1 x 2) + val d = child(min = 6 x 3, pref = 30 x 5, max = 20 x 9) + val horizontal = Stack.horizontal(gap = 7).apply { + next(c) + next(d) + } + + assertEquals(20 x 22, vertical.maximumSize) + assertEquals(32 x 9, horizontal.maximumSize) + } + + 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: 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) + } +}