fix(jetbrains): keep settings row active during popups

This commit is contained in:
kirillk
2026-07-20 17:42:03 -04:00
parent 45b58eade6
commit 851967b3da
7 changed files with 72 additions and 47 deletions
@@ -10,6 +10,7 @@ import ai.kilocode.client.settings.base.settingsListCellBounds
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.SimpleListCellRenderer
import com.intellij.ui.awt.RelativePoint
@@ -38,18 +39,16 @@ internal sealed interface LevelChoice {
* cell; tests substitute a picker that resolves a choice directly.
*/
internal fun interface LevelPicker {
fun show(anchor: JComponent, at: Point, choices: List<LevelChoice>, choose: (LevelChoice) -> Unit)
fun popup(choices: List<LevelChoice>, choose: (LevelChoice) -> Unit): JBPopup?
}
internal object PopupLevelPicker : LevelPicker {
override fun show(anchor: JComponent, at: Point, choices: List<LevelChoice>, choose: (LevelChoice) -> Unit) {
override fun popup(choices: List<LevelChoice>, choose: (LevelChoice) -> Unit): JBPopup =
JBPopupFactory.getInstance()
.createPopupChooserBuilder(choices)
.setRenderer(SimpleListCellRenderer.create("") { levelChoiceLabel(it) })
.setItemChosenCallback(choose)
.createPopup()
.show(RelativePoint(anchor, at))
}
}
internal fun levelChoiceLabel(choice: LevelChoice): String = when (choice) {
@@ -142,9 +141,9 @@ internal class SettingsInlineList(
val item = item(key) ?: return
val idx = index(key) ?: return
val bounds = settingsListCellBounds(view.list, idx, idx == view.list.selectedIndex)[LEVEL_CELL] ?: return
picker.show(view.list, Point(bounds.x, bounds.y + bounds.height), choices(item.row)) { choice ->
choose(key, choice)
}
val popup = picker.popup(choices(item.row)) { choice -> choose(key, choice) } ?: return
trackPopup(popup)
popup.show(RelativePoint(view.list, Point(bounds.x, bounds.y + bounds.height)))
}
private fun item(key: String): PermissionItem? {
@@ -8,6 +8,7 @@ import com.intellij.openapi.actionSystem.ActionToolbar
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.SearchTextField
import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -62,6 +63,12 @@ internal abstract class SettingsInlineListPanel(
view.filter(query)
}
@RequiresEdt
protected fun trackPopup(popup: JBPopup) {
checkEdt()
view.trackPopup(popup)
}
@RequiresEdt
fun setItems(items: List<SettingsListItem>, enabled: Boolean) {
checkEdt()
@@ -68,7 +68,7 @@ internal class SettingsListRenderer(
selected: Boolean,
focused: Boolean,
): JPanel {
val active = selected && list.hasFocus()
val active = selected && (list.hasFocus() || (list as? SettingsListActive)?.active() == true)
val fg = UIUtil.getListForeground(active, active || focused)
val weak = if (active) fg else UiStyle.Colors.weak()
val current = model.items.getOrNull(index)
@@ -132,6 +132,10 @@ internal class SettingsListRenderer(
}
}
internal interface SettingsListActive {
fun active(): Boolean
}
internal class SettingsListActionCell : JBLabel() {
var cellId: String = ""
private set
@@ -2,11 +2,13 @@ package ai.kilocode.client.settings.base
import ai.kilocode.client.session.ui.model.ModelSearch
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupListener
import com.intellij.openapi.ui.popup.LightweightWindowEvent
import com.intellij.ui.CollectionListModel
import com.intellij.ui.ScrollingUtil
import com.intellij.ui.components.JBList
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.xml.util.XmlStringUtil
import com.intellij.util.ui.UIUtil
import java.awt.event.KeyEvent
import java.awt.event.FocusAdapter
@@ -24,17 +26,8 @@ internal class SettingsListView(
private val onCell: (String, String) -> Unit,
) : BaseContentPanel() {
private val model = CollectionListModel<SettingsListItem>()
internal val list = object : JBList<SettingsListItem>(model) {
override fun getToolTipText(event: MouseEvent): String? {
if (!cfg.description) return null
val idx = locationToIndex(event.point)
if (idx < 0) return null
val bounds = getCellBounds(idx, idx) ?: return null
if (!bounds.contains(event.point)) return null
val note = model.getElementAt(idx).description?.takeIf { it.isNotBlank() } ?: return null
val text = note.lines().joinToString("<br>") { XmlStringUtil.escapeString(it) }
return XmlStringUtil.wrapInHtml(text)
}
internal val list: JBList<SettingsListItem> = object : JBList<SettingsListItem>(model), SettingsListActive {
override fun active(): Boolean = popups > 0
}.apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
setExpandableItemsEnabled(false)
@@ -43,6 +36,7 @@ internal class SettingsListView(
private var items = emptyList<SettingsListItem>()
private var filter = ""
private var press: Press? = null
private var popups = 0
internal var onSelect: (() -> Unit)? = null
fun setEmptyText(text: String) {
@@ -136,6 +130,29 @@ internal class SettingsListView(
list.repaint()
}
@RequiresEdt
fun trackPopup(popup: JBPopup) {
checkEdt()
var tracked = false
fun activate() {
if (tracked) return
tracked = true
popups++
list.repaint()
}
popup.addListener(object : JBPopupListener {
override fun beforeShown(event: LightweightWindowEvent) = activate()
override fun onClosed(event: LightweightWindowEvent) {
if (!tracked) return
tracked = false
popups = maxOf(0, popups - 1)
list.repaint()
}
})
if (popup.isVisible) activate()
}
@RequiresEdt
fun filter(query: String) {
checkEdt()
@@ -40,7 +40,10 @@ class AutoApproveSettingsUiTest : BasePlatformTestCase() {
private lateinit var workspaces: KiloWorkspaceService
private var ui: AutoApproveSettingsUi? = null
private var pick: (List<LevelChoice>) -> LevelChoice = { it.first() }
private val picker = LevelPicker { _, _, choices, choose -> choose(pick(choices)) }
private val picker = LevelPicker { choices, choose ->
choose(pick(choices))
null
}
override fun setUp() {
super.setUp()
@@ -4,6 +4,7 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.settings.base.SettingsListItem
import ai.kilocode.client.settings.base.settingsListCellBounds
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBList
import com.intellij.util.ui.UIUtil
@@ -148,14 +149,10 @@ class SettingsInlineListTest : BasePlatformTestCase() {
var offered: List<LevelChoice> = emptyList()
private set
override fun show(
anchor: JComponent,
at: Point,
choices: List<LevelChoice>,
choose: (LevelChoice) -> Unit,
) {
override fun popup(choices: List<LevelChoice>, choose: (LevelChoice) -> Unit): JBPopup? {
offered = choices
choose(select(choices))
return null
}
}
@@ -19,7 +19,7 @@ import java.awt.event.MouseEvent
import javax.swing.SwingUtilities
class SettingsListViewTest : BasePlatformTestCase() {
fun `test list owns formatted description tooltip`() {
fun `test list does not duplicate description in tooltip`() {
edt {
val view = SettingsListView("Empty") { _, _ -> }
val row = item("with", "Alpha", "Use <safe> text\nAcross lines")
@@ -31,25 +31,7 @@ class SettingsListViewTest : BasePlatformTestCase() {
val bounds = view.list.getCellBounds(0, 0)
val tip = view.list.getToolTipText(event(view.list, Point(bounds.x + 4, bounds.y + 4)))
assertNotNull(tip)
assertTrue(tip, tip!!.startsWith("<html>"))
assertTrue(tip, tip.contains("Use &lt;safe&gt; text"))
assertTrue(tip, tip.contains("<br>Across lines"))
}
}
fun `test list description tooltip ignores blank rows and outside points`() {
edt {
val view = SettingsListView("Empty") { _, _ -> }
view.update(listOf(item("without", "Beta", null)))
view.list.size = Dimension(320, 80)
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
val bounds = view.list.getCellBounds(0, 0)
assertNull(view.list.getToolTipText(event(view.list, Point(bounds.x + 4, bounds.y + 4))))
assertNull(view.list.getToolTipText(event(view.list, Point(4, bounds.y + bounds.height + 20))))
assertNull(tip)
}
}
@@ -252,6 +234,22 @@ class SettingsListViewTest : BasePlatformTestCase() {
}
}
fun `test active popup paints selected row as active without focus`() {
edt {
val row = item("with", "Alpha", "Description")
val model = CollectionListModel<SettingsListItem>(listOf(row))
val list = object : JBList<SettingsListItem>(model), SettingsListActive {
override fun active(): Boolean = true
}
val renderer = SettingsListRenderer(model, SettingsListConfig.Equal)
renderer.getListCellRendererComponent(list, row, 0, true, false)
val desc = components(renderer).filterIsInstance<JBLabel>().single { it.text == "Description" }
assertEquals(UIUtil.getListForeground(true, true), desc.foreground)
}
}
fun `test preserve no scroll keeps scroll position after row change`() {
edt {
val view = SettingsListView("Empty") { _, _ -> }