mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
refactor(jetbrains): consolidate model picker popup
This commit is contained in:
+32
-314
@@ -2,6 +2,8 @@ package ai.kilocode.client.session.ui.model
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.PickerButton
|
||||
import ai.kilocode.client.ui.picker.PickerPopup
|
||||
import ai.kilocode.client.ui.picker.popupBackground
|
||||
import ai.kilocode.rpc.dto.ModelAutoRoutingDto
|
||||
import ai.kilocode.rpc.dto.ModelCapabilitiesDto
|
||||
import ai.kilocode.rpc.dto.ModelCostDto
|
||||
@@ -9,57 +11,19 @@ import ai.kilocode.rpc.dto.ModelLimitDto
|
||||
import ai.kilocode.rpc.dto.ModelOptionsDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.ModelTerminalBenchDto
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import com.intellij.ide.util.PropertiesComponent
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.ui.popup.JBPopup
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.openapi.ui.popup.PopupShowOptions
|
||||
import com.intellij.openapi.ui.popup.util.PopupUtil
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.DocumentAdapter
|
||||
import com.intellij.ui.NewUI
|
||||
import com.intellij.ui.ListUtil
|
||||
import com.intellij.ui.SearchTextField
|
||||
import com.intellij.ui.ScrollPaneFactory
|
||||
import com.intellij.ui.ScrollingUtil
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.popup.AbstractPopup
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import com.intellij.xml.util.XmlStringUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.GridBagLayout
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JList
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.ListSelectionModel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.DocumentEvent
|
||||
import javax.swing.Icon
|
||||
|
||||
private val popupBackground: Color
|
||||
get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()
|
||||
|
||||
private const val MODEL_PICKER_MIN_WIDTH = 420
|
||||
private const val MODEL_PICKER_MAX_WIDTH = 760
|
||||
private const val MODEL_PICKER_MAX_VISIBLE_ROWS = 10
|
||||
private const val MODEL_PICKER_EMPTY_LIST_HEIGHT = 120
|
||||
private val EXPAND: Icon = IconLoader.getIcon("/icons/expand.svg", ModelPicker::class.java)
|
||||
private val COLLAPSE: Icon = IconLoader.getIcon("/icons/collapse.svg", ModelPicker::class.java)
|
||||
|
||||
class ModelPicker : PickerButton() {
|
||||
|
||||
@@ -170,25 +134,13 @@ class ModelPicker : PickerButton() {
|
||||
}
|
||||
|
||||
private fun showPopup() {
|
||||
var expanded = props.getBoolean(MODEL_PICKER_EXPANDED_KEY, false)
|
||||
val rows = modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall)
|
||||
val model = CollectionListModel(rows)
|
||||
val list = JBList(model).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
isFocusable = false
|
||||
emptyText.text = KiloBundle.message("model.picker.no.matches")
|
||||
background = popupBackground
|
||||
border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false))
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
}
|
||||
list.cellRenderer = ModelPickerRenderer(
|
||||
model = model,
|
||||
val data = CollectionListModel(modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall))
|
||||
var popup: PickerPopup<ModelPickerRow>? = null
|
||||
val renderer = ModelPickerRenderer(
|
||||
model = data,
|
||||
active = { selected?.key },
|
||||
favorites = { favoriteKeys() },
|
||||
)
|
||||
val search = SearchTextField(false).apply {
|
||||
textEditor.emptyText.text = KiloBundle.message("model.picker.search")
|
||||
}
|
||||
var refreshFavorite: (Item) -> Unit = {}
|
||||
val details = ModelDetailsPanel(
|
||||
favorites = { favoriteKeys() },
|
||||
@@ -196,77 +148,17 @@ class ModelPicker : PickerButton() {
|
||||
).apply {
|
||||
background = popupBackground
|
||||
}
|
||||
val expand = HoverIcon().apply {
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
}
|
||||
val ins = PopupUtil.getListInsets(false, false)
|
||||
val side = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() + JBUI.CurrentTheme.Popup.Selection.innerInsets().right
|
||||
val expandWrap = JPanel(GridBagLayout()).apply {
|
||||
background = popupBackground
|
||||
border = JBUI.Borders.empty(0, JBUI.CurrentTheme.ActionsList.elementIconGap(), 0, side)
|
||||
add(expand)
|
||||
}
|
||||
val head = JPanel(BorderLayout()).apply {
|
||||
background = popupBackground
|
||||
border = JBUI.Borders.empty(ins.top, ins.left, ins.bottom, 0)
|
||||
add(search, BorderLayout.CENTER)
|
||||
add(expandWrap, BorderLayout.EAST)
|
||||
}
|
||||
|
||||
lateinit var popup: JBPopup
|
||||
lateinit var content: JPanel
|
||||
lateinit var scroll: JScrollPane
|
||||
var shown = false
|
||||
|
||||
fun activeKey(): String? = list.selectedValue?.item?.key
|
||||
|
||||
fun preview(): Item? = list.selectedValue?.item ?: selected
|
||||
|
||||
fun syncDetails() {
|
||||
details.update(preview())
|
||||
}
|
||||
|
||||
fun syncExpand() {
|
||||
expand.icon = if (expanded) COLLAPSE else EXPAND
|
||||
expand.toolTipText = if (expanded) {
|
||||
KiloBundle.message("model.picker.details.minimize")
|
||||
} else {
|
||||
KiloBundle.message("model.picker.details.maximize")
|
||||
}
|
||||
expand.accessibleContext.accessibleName = expand.toolTipText
|
||||
details.isVisible = expanded
|
||||
}
|
||||
|
||||
fun choose(idx: Int) {
|
||||
list.selectedIndex = idx
|
||||
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
|
||||
}
|
||||
|
||||
fun sync(prefer: String? = activeKey(), at: Int? = null) {
|
||||
val rows = modelPickerRows(items, favorites(), search.text, allowEmpty, emptyText, includeSmall)
|
||||
model.replaceAll(rows)
|
||||
val idx = at?.let { modelPickerIndex(rows, it) }?.takeIf { it >= 0 }
|
||||
?: modelPickerIndex(rows, prefer).takeIf { it >= 0 }
|
||||
?: modelPickerIndex(rows, selected?.key).takeIf { it >= 0 }
|
||||
?: rows.indices.firstOrNull()
|
||||
?: -1
|
||||
if (idx >= 0) choose(idx)
|
||||
else list.clearSelection()
|
||||
syncDetails()
|
||||
}
|
||||
|
||||
fun activate(item: Item) {
|
||||
selected = item
|
||||
refresh()
|
||||
onSelect(item)
|
||||
popup.closeOk(null)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
selected = null
|
||||
refresh()
|
||||
onClear()
|
||||
popup.closeOk(null)
|
||||
}
|
||||
|
||||
fun activate(row: ModelPickerRow) {
|
||||
@@ -278,166 +170,42 @@ class ModelPicker : PickerButton() {
|
||||
activate(item)
|
||||
}
|
||||
|
||||
fun move(step: Int) {
|
||||
val size = model.size
|
||||
if (size <= 0) return
|
||||
val cur = list.selectedIndex.takeIf { it >= 0 } ?: 0
|
||||
val idx = (cur + step).coerceIn(0, size - 1)
|
||||
choose(idx)
|
||||
}
|
||||
|
||||
fun toggle(row: ModelPickerRow) {
|
||||
val item = row.item ?: return
|
||||
val idx = list.selectedIndex
|
||||
onFavoriteToggle(item)
|
||||
sync(at = idx)
|
||||
list.selectedIndex.takeIf { it >= 0 }?.let { repaintRow(list, it) }
|
||||
syncDetails()
|
||||
}
|
||||
|
||||
refreshFavorite = { item ->
|
||||
onFavoriteToggle(item)
|
||||
sync(prefer = item.key)
|
||||
list.repaint()
|
||||
popup?.refresh(prefer = item.key)
|
||||
popup?.repaint()
|
||||
}
|
||||
|
||||
fun resize() {
|
||||
val size = computeInitialPopupSize(list, scroll, head, expanded)
|
||||
content.preferredSize = size
|
||||
if (expanded) {
|
||||
details.preferredSize = Dimension(size.width - scroll.preferredSize.width, scroll.preferredSize.height)
|
||||
}
|
||||
content.revalidate()
|
||||
content.repaint()
|
||||
if (shown) popup.setSize(size)
|
||||
}
|
||||
|
||||
fun setExpanded(value: Boolean) {
|
||||
if (expanded == value) return
|
||||
expanded = value
|
||||
props.setValue(MODEL_PICKER_EXPANDED_KEY, value.toString())
|
||||
if (!expanded) list.clearSelection()
|
||||
syncExpand()
|
||||
syncDetails()
|
||||
resize()
|
||||
}
|
||||
|
||||
search.textEditor.document.addDocumentListener(object : DocumentAdapter() {
|
||||
override fun textChanged(e: DocumentEvent) {
|
||||
sync()
|
||||
}
|
||||
})
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ move(-1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
popup = PickerPopup(
|
||||
anchor = this,
|
||||
placement = when (placement) {
|
||||
Placement.ABOVE -> PickerPopup.Placement.ABOVE
|
||||
Placement.BELOW -> PickerPopup.Placement.BELOW
|
||||
},
|
||||
rows = { q -> modelPickerRows(items, favorites(), q, allowEmpty, emptyText, includeSmall) },
|
||||
model = data,
|
||||
renderer = renderer,
|
||||
key = { it.key },
|
||||
mode = PickerPopup.Mode.Single,
|
||||
onPrimary = ::activate,
|
||||
sectionTitle = ::modelPickerSectionTitle,
|
||||
trailingHit = ModelPickerRenderer::isFavoriteClick,
|
||||
onTrailing = ::toggle,
|
||||
search = true,
|
||||
details = details,
|
||||
onPreview = { details.update(it?.item ?: selected) },
|
||||
expandStateKey = MODEL_PICKER_EXPANDED_KEY,
|
||||
minWidth = MODEL_PICKER_MIN_WIDTH,
|
||||
maxWidth = MODEL_PICKER_MAX_WIDTH,
|
||||
maxVisibleRows = MODEL_PICKER_MAX_VISIBLE_ROWS,
|
||||
emptyListHeight = MODEL_PICKER_EMPTY_LIST_HEIGHT,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ move(1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ list.selectedValue?.let(::activate) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ popup.cancel() },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ list.selectedValue?.let(::toggle) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
list.registerKeyboardAction(
|
||||
{ list.selectedValue?.let(::activate) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
list.registerKeyboardAction(
|
||||
{ popup.cancel() },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
list.registerKeyboardAction(
|
||||
{ list.selectedValue?.let(::toggle) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
list.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
|
||||
val row = list.locationToIndex(e.point)
|
||||
val bounds = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return
|
||||
if (!bounds.contains(e.point)) return
|
||||
val value = model.getElementAt(row)
|
||||
if (ModelPickerRenderer.isFavoriteClick(list, bounds, e.point)) {
|
||||
toggle(value)
|
||||
e.consume()
|
||||
return
|
||||
}
|
||||
activate(value)
|
||||
}
|
||||
})
|
||||
expand.addActionListener { setExpanded(!expanded) }
|
||||
list.addListSelectionListener {
|
||||
if (!it.valueIsAdjusting && expanded) syncDetails()
|
||||
}
|
||||
ListUtil.installAutoSelectOnMouseMove(list)
|
||||
ScrollingUtil.installActions(list)
|
||||
|
||||
scroll = ScrollPaneFactory.createScrollPane(list).apply {
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
border = JBUI.Borders.empty()
|
||||
viewportBorder = JBUI.Borders.empty()
|
||||
background = popupBackground
|
||||
viewport.background = popupBackground
|
||||
viewport.isOpaque = true
|
||||
}
|
||||
content = JPanel(BorderLayout()).apply {
|
||||
background = popupBackground
|
||||
border = JBUI.Borders.empty()
|
||||
add(head, BorderLayout.NORTH)
|
||||
add(scroll, BorderLayout.CENTER)
|
||||
add(details, BorderLayout.EAST)
|
||||
}
|
||||
PopupUtil.applyNewUIBackground(list)
|
||||
list.background = popupBackground
|
||||
AbstractPopup.customizeSearchFieldLook(search, true)
|
||||
search.background = popupBackground
|
||||
|
||||
sync(selected?.key)
|
||||
syncExpand()
|
||||
syncDetails()
|
||||
content.preferredSize = computeInitialPopupSize(list, scroll, head, expanded)
|
||||
if (expanded) details.preferredSize = Dimension(content.preferredSize.width - scroll.preferredSize.width, scroll.preferredSize.height)
|
||||
popup = JBPopupFactory.getInstance()
|
||||
.createComponentPopupBuilder(content, search.textEditor)
|
||||
.setRequestFocus(true)
|
||||
.setFocusable(true)
|
||||
.setCancelOnClickOutside(true)
|
||||
.setCancelKeyEnabled(true)
|
||||
.setCancelOnWindowDeactivation(true)
|
||||
.setLocateWithinScreenBounds(true)
|
||||
.setResizable(false)
|
||||
.setMovable(false)
|
||||
.createPopup()
|
||||
Disposer.register(popup, details)
|
||||
|
||||
when (placement) {
|
||||
Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(this))
|
||||
Placement.BELOW -> popup.showUnderneathOf(this)
|
||||
}
|
||||
shown = true
|
||||
SwingUtilities.invokeLater {
|
||||
search.textEditor.requestFocusInWindow()
|
||||
search.selectText()
|
||||
list.selectedIndex.takeIf { it >= 0 }?.let(list::ensureIndexIsVisible)
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private fun favoriteKeys(): Set<String> = favorites().mapTo(mutableSetOf()) { "${it.providerID}/${it.modelID}" }
|
||||
@@ -457,56 +225,6 @@ internal data class ModelPickerRow(
|
||||
val isEmpty: Boolean get() = item == null
|
||||
}
|
||||
|
||||
private fun computeInitialPopupSize(
|
||||
list: JList<ModelPickerRow>,
|
||||
scroll: JScrollPane,
|
||||
head: JComponent,
|
||||
expanded: Boolean = false,
|
||||
): Dimension {
|
||||
val width = computeListPreferredWidth(list)
|
||||
list.fixedCellWidth = width
|
||||
|
||||
val height = computeListPreferredHeight(list)
|
||||
val bar = if (list.model.size > MODEL_PICKER_MAX_VISIBLE_ROWS) scroll.verticalScrollBar.preferredSize.width else 0
|
||||
val listWidth = width + bar
|
||||
val detailWidth = if (expanded) width else 0
|
||||
val content = Dimension(listWidth + detailWidth, head.preferredSize.height + height)
|
||||
scroll.preferredSize = Dimension(listWidth, height)
|
||||
return content
|
||||
}
|
||||
|
||||
private fun computeListPreferredWidth(list: JList<ModelPickerRow>): Int {
|
||||
val renderer = list.cellRenderer ?: return JBUI.scale(MODEL_PICKER_MIN_WIDTH)
|
||||
val model = list.model
|
||||
val max = (0 until model.size).maxOfOrNull { idx ->
|
||||
val value = model.getElementAt(idx)
|
||||
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.width
|
||||
} ?: 0
|
||||
val insets = list.insets
|
||||
return (max + insets.left + insets.right).coerceIn(
|
||||
JBUI.scale(MODEL_PICKER_MIN_WIDTH),
|
||||
JBUI.scale(MODEL_PICKER_MAX_WIDTH),
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeListPreferredHeight(list: JList<ModelPickerRow>): Int {
|
||||
val renderer = list.cellRenderer ?: return JBUI.scale(MODEL_PICKER_EMPTY_LIST_HEIGHT)
|
||||
val model = list.model
|
||||
val count = model.size.coerceAtMost(MODEL_PICKER_MAX_VISIBLE_ROWS)
|
||||
if (count <= 0) return JBUI.scale(MODEL_PICKER_EMPTY_LIST_HEIGHT)
|
||||
val height = (0 until count).sumOf { idx ->
|
||||
val value = model.getElementAt(idx)
|
||||
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.height
|
||||
}
|
||||
val insets = list.insets
|
||||
return height + insets.top + insets.bottom
|
||||
}
|
||||
|
||||
private fun repaintRow(list: JList<*>, index: Int) {
|
||||
if (index < 0) return
|
||||
list.getCellBounds(index, index)?.let(list::repaint)
|
||||
}
|
||||
|
||||
internal object ModelSearch {
|
||||
fun matches(query: String, text: String): Boolean {
|
||||
val q = query.lowercase().trim()
|
||||
|
||||
+99
-131
@@ -1,119 +1,52 @@
|
||||
package ai.kilocode.client.session.ui.model
|
||||
|
||||
import ai.kilocode.client.session.ui.PickerRow
|
||||
import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.picker.PickerListRenderer
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.GroupHeaderSeparator
|
||||
import com.intellij.ui.NewUI
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.EmptyIcon
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Point
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JList
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.ListCellRenderer
|
||||
import javax.swing.SwingConstants
|
||||
|
||||
private const val FAVORITE_CLICK_AREA_WIDTH = 32
|
||||
|
||||
internal class ModelPickerRenderer(
|
||||
private val model: CollectionListModel<ModelPickerRow>,
|
||||
private val active: () -> String?,
|
||||
internal class ModelPickerRenderer private constructor(
|
||||
model: CollectionListModel<ModelPickerRow>,
|
||||
active: () -> String?,
|
||||
private val favorites: () -> Set<String>,
|
||||
) : JPanel(BorderLayout()), ListCellRenderer<ModelPickerRow> {
|
||||
private val parts: Parts,
|
||||
) : PickerListRenderer<ModelPickerRow>(
|
||||
model = model,
|
||||
checked = { it.key == active() },
|
||||
sectionTitle = ::modelPickerSectionTitle,
|
||||
content = parts.head,
|
||||
trailing = parts.star,
|
||||
) {
|
||||
constructor(
|
||||
model: CollectionListModel<ModelPickerRow>,
|
||||
active: () -> String?,
|
||||
favorites: () -> Set<String>,
|
||||
) : this(model, active, favorites, Parts.create())
|
||||
|
||||
companion object {
|
||||
val DATA_COLLECTED: Icon = IconLoader.getIcon("/icons/book-open-check.svg", ModelPickerRenderer::class.java)
|
||||
val checked: Icon = AllIcons.Actions.Checked
|
||||
val empty: Icon = EmptyIcon.create(checked)
|
||||
val checked: Icon = PickerListRenderer.checkedIcon
|
||||
val empty: Icon = PickerListRenderer.emptyIcon
|
||||
|
||||
fun isFavoriteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean {
|
||||
val width = JBUI.scale(FAVORITE_CLICK_AREA_WIDTH)
|
||||
val inset = favoriteInset(list)
|
||||
if (list.componentOrientation.isLeftToRight) {
|
||||
val right = bounds.x + bounds.width - inset
|
||||
return point.x in (right - width)..right
|
||||
}
|
||||
val left = bounds.x + inset
|
||||
return point.x in left..(left + width)
|
||||
return PickerListRenderer.trailingClickZone(list, bounds, point, FAVORITE_CLICK_AREA_WIDTH)
|
||||
}
|
||||
|
||||
private fun favoriteInset(list: JList<*>): Int {
|
||||
if (!NewUI.isEnabled()) return 0
|
||||
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
|
||||
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
|
||||
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
|
||||
}
|
||||
}
|
||||
|
||||
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
|
||||
private val top = JPanel(BorderLayout()).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
add(sep, BorderLayout.NORTH)
|
||||
}
|
||||
private val check = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val title = SimpleColoredComponent()
|
||||
private val badge = FilledBadgeIcon(
|
||||
ModelText.freeLabel(),
|
||||
UiStyle.Badge.Highlight,
|
||||
)
|
||||
private val badgeLabel = BadgeLabel(badge).apply {
|
||||
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
}
|
||||
private val byok = FilledBadgeIcon(
|
||||
"BYOK",
|
||||
UiStyle.Badge.Highlight,
|
||||
)
|
||||
private val byokLabel = BadgeLabel(byok).apply {
|
||||
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
}
|
||||
private val warn = JBLabel(DATA_COLLECTED).apply {
|
||||
toolTipText = ModelText.dataCollected()
|
||||
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
}
|
||||
private val provider = JBLabel()
|
||||
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
|
||||
add(title)
|
||||
add(warn)
|
||||
add(badgeLabel)
|
||||
add(byokLabel)
|
||||
add(provider)
|
||||
}
|
||||
private val star = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val row = JPanel(BorderLayout()).apply {
|
||||
add(check, BorderLayout.WEST)
|
||||
add(head, BorderLayout.CENTER)
|
||||
}
|
||||
private val wrap = PickerRow()
|
||||
|
||||
init {
|
||||
isOpaque = true
|
||||
top.isOpaque = true
|
||||
UiStyle.Components.transparent(row, check, title, head, warn, provider, star)
|
||||
row.border = JBUI.Borders.empty(
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.pad(),
|
||||
)
|
||||
wrap.setContent(row, star)
|
||||
add(top, BorderLayout.NORTH)
|
||||
add(wrap, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
override fun getListCellRendererComponent(
|
||||
@@ -123,70 +56,105 @@ internal class ModelPickerRenderer(
|
||||
selected: Boolean,
|
||||
focused: Boolean,
|
||||
): JPanel {
|
||||
val focus = selected || list.hasFocus() || focused
|
||||
val fg = UIUtil.getListForeground(selected, focus)
|
||||
val weak = if (selected) fg else UiStyle.Colors.weak()
|
||||
val current = model.items.getOrNull(index)
|
||||
val section = if (current === value) modelPickerSectionTitle(model.items, index) else null
|
||||
return super.getListCellRendererComponent(list, value, index, selected, focused) as JPanel
|
||||
}
|
||||
|
||||
background = list.background
|
||||
top.background = list.background
|
||||
wrap.update(list, selected, focus)
|
||||
sep.caption = section
|
||||
sep.setHideLine(index == 0)
|
||||
top.isVisible = section != null
|
||||
|
||||
check.icon = if (value.key == active()) checked else empty
|
||||
title.clear()
|
||||
override fun update(
|
||||
value: ModelPickerRow,
|
||||
index: Int,
|
||||
selected: Boolean,
|
||||
focused: Boolean,
|
||||
foreground: java.awt.Color,
|
||||
weak: java.awt.Color,
|
||||
) {
|
||||
parts.title.clear()
|
||||
val item = value.item
|
||||
if (item == null) {
|
||||
title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg))
|
||||
badgeLabel.isVisible = false
|
||||
byokLabel.isVisible = false
|
||||
warn.isVisible = false
|
||||
provider.isVisible = false
|
||||
star.icon = EmptyIcon.ICON_16
|
||||
top.invalidate()
|
||||
return this
|
||||
parts.title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, foreground))
|
||||
parts.badgeLabel.isVisible = false
|
||||
parts.byokLabel.isVisible = false
|
||||
parts.warn.isVisible = false
|
||||
parts.provider.isVisible = false
|
||||
parts.star.icon = EmptyIcon.ICON_16
|
||||
return
|
||||
}
|
||||
val name = ModelText.parts(item)
|
||||
if (name.provider != null) {
|
||||
title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
|
||||
title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
|
||||
parts.title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
|
||||
parts.title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
|
||||
}
|
||||
title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg))
|
||||
parts.title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, foreground))
|
||||
|
||||
warn.isVisible = ModelText.collectsData(item)
|
||||
badgeLabel.isVisible = item.free && !item.byok
|
||||
byokLabel.isVisible = item.byok
|
||||
provider.isVisible = value.favorite
|
||||
provider.text = item.providerName
|
||||
provider.foreground = weak
|
||||
provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
parts.warn.isVisible = ModelText.collectsData(item)
|
||||
parts.badgeLabel.isVisible = item.free && !item.byok
|
||||
parts.byokLabel.isVisible = item.byok
|
||||
parts.provider.isVisible = value.favorite
|
||||
parts.provider.text = item.providerName
|
||||
parts.provider.foreground = weak
|
||||
parts.provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
|
||||
val fav = item.key in favorites()
|
||||
star.icon = when {
|
||||
parts.star.icon = when {
|
||||
fav -> AllIcons.Nodes.Favorite
|
||||
selected -> AllIcons.Nodes.NotFavoriteOnHover
|
||||
else -> EmptyIcon.ICON_16
|
||||
}
|
||||
|
||||
top.invalidate()
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
internal fun starIcon(): Icon? = star.icon
|
||||
internal fun starIcon(): Icon? = parts.star.icon
|
||||
|
||||
internal fun badgeVisible(): Boolean = badgeLabel.isVisible
|
||||
internal fun badgeVisible(): Boolean = parts.badgeLabel.isVisible
|
||||
|
||||
internal fun badgeText(): String = badge.text
|
||||
internal fun badgeText(): String = parts.badge.text
|
||||
|
||||
internal fun byokVisible(): Boolean = byokLabel.isVisible
|
||||
internal fun byokVisible(): Boolean = parts.byokLabel.isVisible
|
||||
|
||||
internal fun warningVisible(): Boolean = warn.isVisible
|
||||
internal fun warningVisible(): Boolean = parts.warn.isVisible
|
||||
|
||||
internal fun warningTooltip(): String? = warn.toolTipText
|
||||
internal fun warningTooltip(): String? = parts.warn.toolTipText
|
||||
|
||||
private class BadgeLabel(icon: Icon) : JBLabel(icon)
|
||||
|
||||
private data class Parts(
|
||||
val title: SimpleColoredComponent,
|
||||
val badge: FilledBadgeIcon,
|
||||
val badgeLabel: BadgeLabel,
|
||||
val byokLabel: BadgeLabel,
|
||||
val warn: JBLabel,
|
||||
val provider: JBLabel,
|
||||
val star: JBLabel,
|
||||
val head: JPanel,
|
||||
) {
|
||||
companion object {
|
||||
fun create(): Parts {
|
||||
val title = SimpleColoredComponent()
|
||||
val badge = FilledBadgeIcon(ModelText.freeLabel(), UiStyle.Badge.Highlight)
|
||||
val badgeLabel = BadgeLabel(badge).apply {
|
||||
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
}
|
||||
val byok = FilledBadgeIcon("BYOK", UiStyle.Badge.Highlight)
|
||||
val byokLabel = BadgeLabel(byok).apply {
|
||||
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
}
|
||||
val warn = JBLabel(DATA_COLLECTED).apply {
|
||||
toolTipText = ModelText.dataCollected()
|
||||
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
}
|
||||
val provider = JBLabel()
|
||||
val star = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
|
||||
add(title)
|
||||
add(warn)
|
||||
add(badgeLabel)
|
||||
add(byokLabel)
|
||||
add(provider)
|
||||
}
|
||||
UiStyle.Components.transparent(title, head, warn, provider, star)
|
||||
return Parts(title, badge, badgeLabel, byokLabel, warn, provider, star, head)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package ai.kilocode.client.ui.picker
|
||||
|
||||
import ai.kilocode.client.session.ui.PickerRow
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.GroupHeaderSeparator
|
||||
import com.intellij.ui.NewUI
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.EmptyIcon
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Point
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JList
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.ListCellRenderer
|
||||
import javax.swing.SwingConstants
|
||||
|
||||
internal abstract class PickerListRenderer<T>(
|
||||
private val model: CollectionListModel<T>,
|
||||
private val checked: (T) -> Boolean,
|
||||
private val sectionTitle: (List<T>, Int) -> String?,
|
||||
protected val content: JComponent,
|
||||
private val trailing: JComponent? = null,
|
||||
) : JPanel(BorderLayout()), ListCellRenderer<T> {
|
||||
companion object {
|
||||
val checkedIcon: Icon = AllIcons.Actions.Checked
|
||||
val emptyIcon: Icon = EmptyIcon.create(checkedIcon)
|
||||
|
||||
fun trailingClickZone(list: JList<*>, bounds: Rectangle, point: Point, width: Int): Boolean {
|
||||
val size = JBUI.scale(width)
|
||||
val inset = trailingInset(list)
|
||||
if (list.componentOrientation.isLeftToRight) {
|
||||
val right = bounds.x + bounds.width - inset
|
||||
return point.x in (right - size)..right
|
||||
}
|
||||
val left = bounds.x + inset
|
||||
return point.x in left..(left + size)
|
||||
}
|
||||
|
||||
private fun trailingInset(list: JList<*>): Int {
|
||||
if (!NewUI.isEnabled()) return 0
|
||||
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
|
||||
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
|
||||
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
|
||||
}
|
||||
}
|
||||
|
||||
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
|
||||
private val top = JPanel(BorderLayout()).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
add(sep, BorderLayout.NORTH)
|
||||
}
|
||||
private val check = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val row = JPanel(BorderLayout()).apply {
|
||||
border = JBUI.Borders.empty(
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.pad(),
|
||||
)
|
||||
add(check, BorderLayout.WEST)
|
||||
add(content, BorderLayout.CENTER)
|
||||
}
|
||||
private val wrap = PickerRow()
|
||||
|
||||
init {
|
||||
isOpaque = true
|
||||
top.isOpaque = true
|
||||
UiStyle.Components.transparent(row, check, content)
|
||||
trailing?.let { UiStyle.Components.transparent(it) }
|
||||
wrap.setContent(row, trailing)
|
||||
add(top, BorderLayout.NORTH)
|
||||
add(wrap, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<out T>,
|
||||
value: T,
|
||||
index: Int,
|
||||
selected: Boolean,
|
||||
focused: Boolean,
|
||||
): Component {
|
||||
val focus = selected || list.hasFocus() || focused
|
||||
val fg = UIUtil.getListForeground(selected, focus)
|
||||
val weak = if (selected) fg else UiStyle.Colors.weak()
|
||||
val current = model.items.getOrNull(index)
|
||||
val section = if (current === value) sectionTitle(model.items, index) else null
|
||||
|
||||
background = list.background
|
||||
top.background = list.background
|
||||
wrap.update(list, selected, focus)
|
||||
sep.caption = section
|
||||
sep.setHideLine(index == 0)
|
||||
top.isVisible = section != null
|
||||
check.icon = if (checked(value)) checkedIcon else emptyIcon
|
||||
update(value, index, selected, focus, fg, weak)
|
||||
top.invalidate()
|
||||
return this
|
||||
}
|
||||
|
||||
protected abstract fun update(
|
||||
value: T,
|
||||
index: Int,
|
||||
selected: Boolean,
|
||||
focused: Boolean,
|
||||
foreground: java.awt.Color,
|
||||
weak: java.awt.Color,
|
||||
)
|
||||
}
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
package ai.kilocode.client.ui.picker
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
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 com.intellij.ide.util.PropertiesComponent
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.ui.popup.JBPopup
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.openapi.ui.popup.PopupShowOptions
|
||||
import com.intellij.openapi.ui.popup.util.PopupUtil
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.DocumentAdapter
|
||||
import com.intellij.ui.ListUtil
|
||||
import com.intellij.ui.NewUI
|
||||
import com.intellij.ui.ScrollPaneFactory
|
||||
import com.intellij.ui.ScrollingUtil
|
||||
import com.intellij.ui.SearchTextField
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.popup.AbstractPopup
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JList
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.ListSelectionModel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.DocumentEvent
|
||||
|
||||
internal val popupBackground: Color
|
||||
get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()
|
||||
|
||||
private val EXPAND: Icon = IconLoader.getIcon("/icons/expand.svg", PickerPopup::class.java)
|
||||
private val COLLAPSE: Icon = IconLoader.getIcon("/icons/collapse.svg", PickerPopup::class.java)
|
||||
|
||||
internal class PickerPopup<T>(
|
||||
private val anchor: JComponent,
|
||||
private val placement: Placement,
|
||||
private val rows: (String) -> List<T>,
|
||||
private val model: CollectionListModel<T>,
|
||||
private val renderer: PickerListRenderer<T>,
|
||||
private val key: (T) -> Any? = { it as Any },
|
||||
private val mode: Mode,
|
||||
private val onPrimary: (T) -> Unit,
|
||||
private val sectionTitle: (List<T>, Int) -> String? = { _, _ -> null },
|
||||
private val trailingHit: ((JList<*>, java.awt.Rectangle, java.awt.Point) -> Boolean)? = null,
|
||||
private val onTrailing: ((T) -> Unit)? = null,
|
||||
private val search: Boolean = false,
|
||||
private val toolbar: List<JComponent> = emptyList(),
|
||||
private val details: JComponent? = null,
|
||||
private val onPreview: (T?) -> Unit = {},
|
||||
private val expandStateKey: String? = null,
|
||||
private val minWidth: Int = 420,
|
||||
private val maxWidth: Int = 760,
|
||||
private val maxVisibleRows: Int = 10,
|
||||
private val emptyListHeight: Int = 120,
|
||||
private val emptyText: String = KiloBundle.message("model.picker.no.matches"),
|
||||
) {
|
||||
enum class Placement { ABOVE, BELOW, UNDERNEATH }
|
||||
enum class Mode { Single, Multi }
|
||||
|
||||
private val props get() = PropertiesComponent.getInstance()
|
||||
private var expanded = expandStateKey?.let { props.getBoolean(it, false) } ?: false
|
||||
private val list = JBList(model).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
isFocusable = !search
|
||||
this.emptyText.text = this@PickerPopup.emptyText
|
||||
background = popupBackground
|
||||
border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false))
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
cellRenderer = renderer
|
||||
}
|
||||
private val field = if (search) SearchTextField(false).apply {
|
||||
textEditor.emptyText.text = KiloBundle.message("model.picker.search")
|
||||
} else null
|
||||
private val expand = details?.let {
|
||||
HoverIcon().apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) }
|
||||
}
|
||||
private lateinit var popup: JBPopup
|
||||
private lateinit var content: JPanel
|
||||
private lateinit var scroll: JScrollPane
|
||||
private var shown = false
|
||||
|
||||
fun show(): JBPopup {
|
||||
installSearch()
|
||||
installKeys(list)
|
||||
installMouse()
|
||||
installExpand()
|
||||
list.addListSelectionListener {
|
||||
if (!it.valueIsAdjusting && expanded) preview()
|
||||
}
|
||||
ListUtil.installAutoSelectOnMouseMove(list)
|
||||
ScrollingUtil.installActions(list)
|
||||
|
||||
val head = header()
|
||||
scroll = ScrollPaneFactory.createScrollPane(list).apply {
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
border = JBUI.Borders.empty()
|
||||
viewportBorder = JBUI.Borders.empty()
|
||||
background = popupBackground
|
||||
viewport.background = popupBackground
|
||||
viewport.isOpaque = true
|
||||
}
|
||||
content = JPanel(BorderLayout()).apply {
|
||||
background = popupBackground
|
||||
border = JBUI.Borders.empty()
|
||||
add(head, BorderLayout.NORTH)
|
||||
add(scroll, BorderLayout.CENTER)
|
||||
details?.let { add(it, BorderLayout.EAST) }
|
||||
}
|
||||
PopupUtil.applyNewUIBackground(list)
|
||||
list.background = popupBackground
|
||||
field?.let {
|
||||
AbstractPopup.customizeSearchFieldLook(it, true)
|
||||
it.background = popupBackground
|
||||
}
|
||||
refresh()
|
||||
syncExpand()
|
||||
preview()
|
||||
resize(head)
|
||||
popup = JBPopupFactory.getInstance()
|
||||
.createComponentPopupBuilder(content, field?.textEditor ?: list)
|
||||
.setRequestFocus(true)
|
||||
.setFocusable(true)
|
||||
.setCancelOnClickOutside(true)
|
||||
.setCancelKeyEnabled(true)
|
||||
.setCancelOnWindowDeactivation(true)
|
||||
.setLocateWithinScreenBounds(true)
|
||||
.setResizable(false)
|
||||
.setMovable(false)
|
||||
.createPopup()
|
||||
if (details is Disposable) Disposer.register(popup, details)
|
||||
when (placement) {
|
||||
Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(anchor))
|
||||
Placement.BELOW,
|
||||
Placement.UNDERNEATH -> popup.showUnderneathOf(anchor)
|
||||
}
|
||||
shown = true
|
||||
SwingUtilities.invokeLater {
|
||||
field?.let {
|
||||
it.textEditor.requestFocusInWindow()
|
||||
it.selectText()
|
||||
} ?: list.requestFocusInWindow()
|
||||
list.selectedIndex.takeIf { it >= 0 }?.let(list::ensureIndexIsVisible)
|
||||
}
|
||||
return popup
|
||||
}
|
||||
|
||||
fun refresh(prefer: Any? = selectedKey(), at: Int? = null) {
|
||||
val data = rows(field?.text.orEmpty())
|
||||
model.replaceAll(data)
|
||||
val idx = at?.takeIf { it in data.indices }
|
||||
?: prefer?.let { value -> data.indexOfFirst { it == value || key(it) == value }.takeIf { it >= 0 } }
|
||||
?: data.indices.firstOrNull()
|
||||
?: -1
|
||||
if (idx >= 0) choose(idx) else list.clearSelection()
|
||||
preview()
|
||||
}
|
||||
|
||||
fun repaint() {
|
||||
list.repaint()
|
||||
}
|
||||
|
||||
private fun header(): JComponent {
|
||||
val ins = PopupUtil.getListInsets(false, false)
|
||||
val pad = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
|
||||
val head = JPanel(BorderLayout()).apply {
|
||||
background = popupBackground
|
||||
border = JBUI.Borders.empty(ins.top, ins.left, ins.bottom, pad)
|
||||
}
|
||||
field?.let { head.add(it.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER) }
|
||||
val actions = toolbar + listOfNotNull(expand)
|
||||
if (actions.isNotEmpty()) {
|
||||
val bar = Stack.horizontal(JBUI.CurrentTheme.ActionsList.elementIconGap()).apply {
|
||||
actions.forEach { next(it) }
|
||||
}
|
||||
head.add(bar.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
private fun installSearch() {
|
||||
val editor = field?.textEditor ?: return
|
||||
editor.document.addDocumentListener(object : DocumentAdapter() {
|
||||
override fun textChanged(e: DocumentEvent) {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
editor.registerKeyboardAction({ move(-1) }, KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), JComponent.WHEN_FOCUSED)
|
||||
editor.registerKeyboardAction({ move(1) }, KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), JComponent.WHEN_FOCUSED)
|
||||
installKeys(editor)
|
||||
}
|
||||
|
||||
private fun installKeys(component: JComponent) {
|
||||
component.registerKeyboardAction({ list.selectedValue?.let(::primary) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED)
|
||||
component.registerKeyboardAction({ popup.cancel() }, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_FOCUSED)
|
||||
if (mode == Mode.Multi) {
|
||||
component.registerKeyboardAction({ list.selectedValue?.let(::primary) }, KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), JComponent.WHEN_FOCUSED)
|
||||
return
|
||||
}
|
||||
if (onTrailing != null) {
|
||||
component.registerKeyboardAction({ list.selectedValue?.let(::trailing) }, KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK), JComponent.WHEN_FOCUSED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installMouse() {
|
||||
list.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
|
||||
val idx = list.locationToIndex(e.point).takeIf { it >= 0 } ?: return
|
||||
val bounds = list.getCellBounds(idx, idx) ?: return
|
||||
if (!bounds.contains(e.point)) return
|
||||
val value = model.getElementAt(idx)
|
||||
if (trailingHit?.invoke(list, bounds, e.point) == true) {
|
||||
trailing(value)
|
||||
e.consume()
|
||||
return
|
||||
}
|
||||
primary(value)
|
||||
e.consume()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun installExpand() {
|
||||
expand?.addActionListener { setExpanded(!expanded) }
|
||||
}
|
||||
|
||||
private fun primary(value: T) {
|
||||
onPrimary(value)
|
||||
if (mode == Mode.Single) {
|
||||
popup.closeOk(null)
|
||||
return
|
||||
}
|
||||
refresh(at = list.selectedIndex)
|
||||
list.repaint()
|
||||
}
|
||||
|
||||
private fun trailing(value: T) {
|
||||
val block = onTrailing ?: return
|
||||
val idx = list.selectedIndex
|
||||
block(value)
|
||||
refresh(at = idx)
|
||||
list.getCellBounds(list.selectedIndex, list.selectedIndex)?.let(list::repaint)
|
||||
preview()
|
||||
}
|
||||
|
||||
private fun choose(idx: Int) {
|
||||
list.selectedIndex = idx
|
||||
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
|
||||
}
|
||||
|
||||
private fun move(step: Int) {
|
||||
val size = model.size
|
||||
if (size <= 0) return
|
||||
val cur = list.selectedIndex.takeIf { it >= 0 } ?: 0
|
||||
choose((cur + step).coerceIn(0, size - 1))
|
||||
}
|
||||
|
||||
private fun preview() {
|
||||
onPreview(list.selectedValue)
|
||||
}
|
||||
|
||||
private fun setExpanded(value: Boolean) {
|
||||
if (expanded == value) return
|
||||
expanded = value
|
||||
expandStateKey?.let { props.setValue(it, value.toString()) }
|
||||
if (!expanded) list.clearSelection()
|
||||
syncExpand()
|
||||
preview()
|
||||
resize(content.getComponent(0) as JComponent)
|
||||
}
|
||||
|
||||
private fun syncExpand() {
|
||||
val details = details ?: return
|
||||
val expand = expand ?: return
|
||||
expand.icon = if (expanded) COLLAPSE else EXPAND
|
||||
expand.toolTipText = if (expanded) {
|
||||
KiloBundle.message("model.picker.details.minimize")
|
||||
} else {
|
||||
KiloBundle.message("model.picker.details.maximize")
|
||||
}
|
||||
expand.accessibleContext.accessibleName = expand.toolTipText
|
||||
details.isVisible = expanded
|
||||
}
|
||||
|
||||
private fun resize(head: JComponent) {
|
||||
val size = computeInitialPopupSize(list, scroll, head, expanded)
|
||||
content.preferredSize = size
|
||||
if (expanded && details != null) {
|
||||
details.preferredSize = Dimension(size.width - scroll.preferredSize.width, scroll.preferredSize.height)
|
||||
}
|
||||
content.revalidate()
|
||||
content.repaint()
|
||||
if (shown) popup.setSize(size)
|
||||
}
|
||||
|
||||
private fun selectedKey(): Any? = list.selectedValue?.let(key)
|
||||
|
||||
private fun computeInitialPopupSize(list: JList<T>, scroll: JScrollPane, head: JComponent, expanded: Boolean): Dimension {
|
||||
val width = maxOf(computeListPreferredWidth(list), head.preferredSize.width.coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth)))
|
||||
list.fixedCellWidth = width
|
||||
val height = computeListPreferredHeight(list)
|
||||
val bar = if (list.model.size > maxVisibleRows) scroll.verticalScrollBar.preferredSize.width else 0
|
||||
val listWidth = width + bar
|
||||
val detailWidth = if (expanded && details != null) width else 0
|
||||
val size = Dimension(listWidth + detailWidth, head.preferredSize.height + height)
|
||||
scroll.preferredSize = Dimension(listWidth, height)
|
||||
return size
|
||||
}
|
||||
|
||||
private fun computeListPreferredWidth(list: JList<T>): Int {
|
||||
val renderer = list.cellRenderer ?: return JBUI.scale(minWidth)
|
||||
val model = list.model
|
||||
val max = (0 until model.size).maxOfOrNull { idx ->
|
||||
val value = model.getElementAt(idx)
|
||||
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.width
|
||||
} ?: 0
|
||||
val ins = list.insets
|
||||
return (max + ins.left + ins.right).coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth))
|
||||
}
|
||||
|
||||
private fun computeListPreferredHeight(list: JList<T>): Int {
|
||||
val renderer = list.cellRenderer ?: return JBUI.scale(emptyListHeight)
|
||||
val model = list.model
|
||||
val count = model.size.coerceAtMost(maxVisibleRows)
|
||||
if (count <= 0) return JBUI.scale(emptyListHeight)
|
||||
val height = (0 until count).sumOf { idx ->
|
||||
val value = model.getElementAt(idx)
|
||||
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.height
|
||||
}
|
||||
val ins = list.insets
|
||||
return height + ins.top + ins.bottom
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user