mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
feat(jetbrains): add stack layout helper
This commit is contained in:
@@ -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.
|
||||
|
||||
+2
-6
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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)
|
||||
|
||||
+5
-12
@@ -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)
|
||||
|
||||
+2
-9
@@ -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<JBHtmlPane>()
|
||||
@@ -125,7 +120,6 @@ class PermissionView(
|
||||
private fun addDetailRow(action: String, target: String?, diffs: List<PermissionFileDiff>) {
|
||||
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)
|
||||
}
|
||||
|
||||
+4
-6
@@ -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
|
||||
}
|
||||
|
||||
+29
-4
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+192
@@ -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<Entry>()
|
||||
|
||||
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()
|
||||
}
|
||||
+28
@@ -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)
|
||||
|
||||
+301
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user