From c886fb8582c5eaae40812209821ca6bdfcb761f2 Mon Sep 17 00:00:00 2001 From: Ivan Semenov Date: Wed, 29 Apr 2020 14:52:47 +0300 Subject: [PATCH] [github] Fix accounts list relogin link Materialise a renderer over a hovered list row instead of complicated mouse listeners Fixes IDEA-233345 GitOrigin-RevId: 992dbdf905046791c90109c3affbf196b69240a8 --- .../authentication/ui/GHAccountsPanel.kt | 245 ++++++------------ .../ui/util/JListHoveredRowMaterialiser.kt | 105 ++++++++ 2 files changed, 181 insertions(+), 169 deletions(-) create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ui/util/JListHoveredRowMaterialiser.kt diff --git a/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GHAccountsPanel.kt b/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GHAccountsPanel.kt index 05185ca2b136..e2766e766909 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GHAccountsPanel.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GHAccountsPanel.kt @@ -12,12 +12,11 @@ import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.ui.CollectionListModel -import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.SimpleTextAttributes.STYLE_PLAIN -import com.intellij.ui.SimpleTextAttributes.STYLE_UNDERLINE import com.intellij.ui.ToolbarDecorator import com.intellij.ui.components.JBList +import com.intellij.ui.components.labels.LinkLabel +import com.intellij.ui.components.labels.LinkListener import com.intellij.util.progress.ProgressVisibilityManager import com.intellij.util.ui.* import com.intellij.util.ui.components.BorderLayoutPanel @@ -31,47 +30,34 @@ import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException import org.jetbrains.plugins.github.i18n.GithubBundle import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider import org.jetbrains.plugins.github.pullrequest.avatars.GHAvatarIconsProvider +import org.jetbrains.plugins.github.ui.util.JListHoveredRowMaterialiser import org.jetbrains.plugins.github.util.CachingGithubUserAvatarLoader import org.jetbrains.plugins.github.util.GithubImageResizer import org.jetbrains.plugins.github.util.GithubUIUtil import java.awt.* -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.* -import javax.swing.event.ListDataEvent -import javax.swing.event.ListDataListener - -private const val LINK_TAG = "EDIT_LINK" internal class GHAccountsPanel(private val project: Project, private val executorFactory: GithubApiRequestExecutor.Factory, private val avatarLoader: CachingGithubUserAvatarLoader, private val imageResizer: GithubImageResizer) : BorderLayoutPanel(), Disposable { - private val accountListModel = CollectionListModel().apply { - // disable link handler when there are no errors - addListDataListener(object : ListDataListener { - override fun contentsChanged(e: ListDataEvent?) = setLinkHandlerEnabled(items.any { it.errorText != null }) - override fun intervalRemoved(e: ListDataEvent?) {} - override fun intervalAdded(e: ListDataEvent?) {} - }) - } + private val accountListModel = CollectionListModel() private val accountList = JBList(accountListModel).apply { val decoratorRenderer = GithubAccountDecoratorRenderer() cellRenderer = decoratorRenderer + JListHoveredRowMaterialiser.install(this, GithubAccountDecoratorRenderer()) UIUtil.putClientProperty(this, UIUtil.NOT_IN_HIERARCHY_COMPONENTS, listOf(decoratorRenderer)) selectionMode = ListSelectionModel.SINGLE_SELECTION emptyText.apply { appendText(GithubBundle.message("accounts.none.added")) - appendSecondaryText(GithubBundle.message("accounts.add"), SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES, { addAccount() }) + appendSecondaryText(GithubBundle.message("accounts.add"), SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES) { addAccount() } appendSecondaryText(" (${KeymapUtil.getFirstKeyboardShortcutText(CommonShortcuts.getNew())})", StatusText.DEFAULT_ATTRIBUTES, null) } } private val progressManager = createListProgressManager() - private val errorLinkHandler = createLinkActivationListener() - private var errorLinkHandlerInstalled = false private var currentTokensMap = mapOf() private val newTokensMap = mutableMapOf() @@ -131,82 +117,6 @@ internal class GHAccountsPanel(private val project: Project, private fun isAccountUnique(login: String, server: GithubServerPath) = accountListModel.items.none { it.account.name == login && it.account.server == server } - /** - * Manages link hover and click for [GithubAccountDecoratorRenderer.loadingError] - * Sets the proper cursor and underlines the link on hover - * - * @see [GithubAccountDecorator.errorText] - * @see [GithubAccountDecorator.showReLoginLink] - * @see [GithubAccountDecorator.errorLinkPointedAt] - */ - private fun createLinkActivationListener() = object : MouseAdapter() { - - override fun mouseMoved(e: MouseEvent) { - val decorator = findDecoratorWithLoginLinkAt(e.point) - if (decorator != null) { - UIUtil.setCursor(accountList, Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) - } - else { - UIUtil.setCursor(accountList, Cursor.getDefaultCursor()) - } - - var hasChanges = false - for (item in accountListModel.items) { - val isLinkPointedAt = item == decorator - hasChanges = hasChanges || isLinkPointedAt != item.errorLinkPointedAt - item.errorLinkPointedAt = isLinkPointedAt - } - if (hasChanges) accountListModel.allContentsChanged() - } - - override fun mouseClicked(e: MouseEvent) { - findDecoratorWithLoginLinkAt(e.point)?.run(::editAccount) - } - - /** - * Checks if mouse is pointed at decorator error link - * - * @return decorator with error link under mouse pointer or null - */ - private fun findDecoratorWithLoginLinkAt(point: Point): GithubAccountDecorator? { - val idx = accountList.locationToIndex(point) - if (idx < 0) return null - - val cellBounds = accountList.getCellBounds(idx, idx) - if (!cellBounds.contains(point)) return null - - val decorator = accountListModel.getElementAt(idx) - if (decorator?.errorText == null) return null - - val rendererComponent = accountList.cellRenderer.getListCellRendererComponent(accountList, decorator, idx, true, true) - rendererComponent.setBounds(cellBounds.x, cellBounds.y, cellBounds.width, cellBounds.height) - UIUtil.layoutRecursively(rendererComponent) - - val rendererRelativeX = point.x - cellBounds.x - val rendererRelativeY = point.y - cellBounds.y - val childComponent = UIUtil.getDeepestComponentAt(rendererComponent, rendererRelativeX, rendererRelativeY) - if (childComponent !is SimpleColoredComponent) return null - - val childRelativeX = rendererRelativeX - childComponent.parent.x - childComponent.x - return if (childComponent.getFragmentTagAt(childRelativeX) == LINK_TAG) decorator else null - } - } - - private fun setLinkHandlerEnabled(enabled: Boolean) { - if (enabled) { - if (!errorLinkHandlerInstalled) { - accountList.addMouseListener(errorLinkHandler) - accountList.addMouseMotionListener(errorLinkHandler) - errorLinkHandlerInstalled = true - } - } - else if (errorLinkHandlerInstalled) { - accountList.removeMouseListener(errorLinkHandler) - accountList.removeMouseMotionListener(errorLinkHandler) - errorLinkHandlerInstalled = false - } - } - fun loadExistingAccountsDetails() { for (accountData in accountListModel.items) { loadAccountDetails(accountData) @@ -286,87 +196,85 @@ internal class GHAccountsPanel(private val project: Project, } override fun dispose() {} -} -private class GithubAccountDecoratorRenderer : ListCellRenderer, JPanel() { - private val accountName = JLabel() + private inner class GithubAccountDecoratorRenderer : ListCellRenderer, JPanel() { + private val accountName = JLabel() - private val serverName = JLabel() - private val profilePicture = JLabel() + private val serverName = JLabel() + private val profilePicture = JLabel() - private val fullName = JLabel() + private val fullName = JLabel() - private val loadingError = SimpleColoredComponent() + private val loadingError = JLabel() + private val reloginLink = LinkLabel(GithubBundle.message("accounts.relogin"), null) - /** - * UPDATE [createLinkActivationListener] IF YOU CHANGE LAYOUT - */ - init { - layout = FlowLayout(FlowLayout.LEFT, 0, 0) - border = JBUI.Borders.empty(5, 8) + /** + * UPDATE [createLinkActivationListener] IF YOU CHANGE LAYOUT + */ + init { + layout = FlowLayout(FlowLayout.LEFT, 0, 0) + border = JBUI.Borders.empty(5, 8) - val namesPanel = JPanel().apply { - layout = GridBagLayout() - border = JBUI.Borders.empty(0, 6, 4, 6) + val namesPanel = JPanel().apply { + layout = GridBagLayout() + border = JBUI.Borders.empty(0, 6, 4, 6) - val bag = GridBag() - .setDefaultInsets(JBUI.insetsRight(UIUtil.DEFAULT_HGAP)) - .setDefaultAnchor(GridBagConstraints.WEST) - .setDefaultFill(GridBagConstraints.VERTICAL) - add(fullName, bag.nextLine().next()) - add(accountName, bag.next()) - add(loadingError, bag.next()) - add(serverName, bag.nextLine().coverLine()) - } - - add(profilePicture) - add(namesPanel) - } - - override fun getListCellRendererComponent(list: JList, - value: GithubAccountDecorator, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean): Component { - UIUtil.setBackgroundRecursively(this, ListUiUtil.WithTallRow.background(list, isSelected, list.hasFocus())) - val primaryTextColor = ListUiUtil.WithTallRow.foreground(isSelected, list.hasFocus()) - val secondaryTextColor = ListUiUtil.WithTallRow.secondaryForeground(list, isSelected) - - accountName.apply { - text = value.account.name - setBold(if (value.details?.name == null) value.projectDefault else false) - foreground = if (value.details?.name == null) primaryTextColor else secondaryTextColor - } - serverName.apply { - text = value.account.server.toString() - foreground = secondaryTextColor - } - profilePicture.apply { - icon = value.getIcon() - } - fullName.apply { - text = value.details?.name - setBold(value.projectDefault) - isVisible = value.details?.name != null - foreground = primaryTextColor - } - loadingError.apply { - clear() - value.errorText?.let { - append(it, SimpleTextAttributes.ERROR_ATTRIBUTES) - append(" ") - if (value.showReLoginLink) append(GithubBundle.message("accounts.relogin"), - if (value.errorLinkPointedAt) - SimpleTextAttributes(STYLE_UNDERLINE, JBUI.CurrentTheme.Link.linkColor()) - else - SimpleTextAttributes(STYLE_PLAIN, JBUI.CurrentTheme.Link.linkColor()), - LINK_TAG) + val bag = GridBag() + .setDefaultInsets(JBUI.insetsRight(UIUtil.DEFAULT_HGAP)) + .setDefaultAnchor(GridBagConstraints.WEST) + .setDefaultFill(GridBagConstraints.VERTICAL) + add(fullName, bag.nextLine().next()) + add(accountName, bag.next()) + add(loadingError, bag.next()) + add(reloginLink, bag.next()) + add(serverName, bag.nextLine().coverLine()) } - } - return this - } - companion object { + add(profilePicture) + add(namesPanel) + } + + override fun getListCellRendererComponent(list: JList, + value: GithubAccountDecorator, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean): Component { + UIUtil.setBackgroundRecursively(this, ListUiUtil.WithTallRow.background(list, isSelected, list.hasFocus())) + val primaryTextColor = ListUiUtil.WithTallRow.foreground(isSelected, list.hasFocus()) + val secondaryTextColor = ListUiUtil.WithTallRow.secondaryForeground(list, isSelected) + + accountName.apply { + text = value.account.name + setBold(if (value.details?.name == null) value.projectDefault else false) + foreground = if (value.details?.name == null) primaryTextColor else secondaryTextColor + } + serverName.apply { + text = value.account.server.toString() + foreground = secondaryTextColor + } + profilePicture.apply { + icon = value.getIcon() + } + fullName.apply { + text = value.details?.name + setBold(value.projectDefault) + isVisible = value.details?.name != null + foreground = primaryTextColor + } + loadingError.apply { + text = value.errorText + foreground = UIUtil.getErrorForeground() + } + reloginLink.apply { + isVisible = value.errorText != null && value.showReLoginLink + setListener(LinkListener { _, _ -> + editAccount(value) + }, null) + } + return this + } + + private fun JLabel.setBold(isBold: Boolean) { font = font.deriveFont(if (isBold) font.style or Font.BOLD else font.style and Font.BOLD.inv()) } @@ -382,7 +290,6 @@ private class GithubAccountDecorator(val account: GithubAccount, var projectDefa var errorText: String? = null var showReLoginLink = false - var errorLinkPointedAt = false override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/plugins/github/src/org/jetbrains/plugins/github/ui/util/JListHoveredRowMaterialiser.kt b/plugins/github/src/org/jetbrains/plugins/github/ui/util/JListHoveredRowMaterialiser.kt new file mode 100644 index 000000000000..7aee8d6849ea --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ui/util/JListHoveredRowMaterialiser.kt @@ -0,0 +1,105 @@ +// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package org.jetbrains.plugins.github.ui.util + +import com.intellij.ui.ExpandedItemListCellRendererWrapper +import java.awt.Component +import java.awt.event.* +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.event.ListDataEvent +import javax.swing.event.ListDataListener +import javax.swing.event.ListSelectionEvent +import javax.swing.event.ListSelectionListener +import kotlin.properties.Delegates + +/** + * Materializes a concrete component in a hovered list cell that matches the ones painted by the renderer + */ +class JListHoveredRowMaterialiser private constructor(private val list: JList, + private val cellRenderer: ListCellRenderer) { + + private var hoveredIndex: Int by Delegates.observable(-1) { _, oldValue, newValue -> + if (newValue != oldValue) + materialiseRendererAt(newValue) + } + private var rendererComponent: Component? = null + + private val listRowHoverListener = object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val point = e.point + val idx = list.locationToIndex(point) + + if (idx >= 0 && list.getCellBounds(idx, idx).contains(point)) { + hoveredIndex = idx + } + else { + hoveredIndex = -1 + } + } + } + + private val listDataListener = object : ListDataListener { + override fun contentsChanged(e: ListDataEvent) { + if (hoveredIndex in e.index0..e.index1) materialiseRendererAt(hoveredIndex) + } + + override fun intervalRemoved(e: ListDataEvent) { + if (hoveredIndex > e.index0 || hoveredIndex > e.index1) hoveredIndex = -1 + } + + override fun intervalAdded(e: ListDataEvent) { + if (hoveredIndex > e.index0 || hoveredIndex > e.index1) hoveredIndex = -1 + } + } + + private val listPresentationListener = object : FocusListener, + ListSelectionListener, + ComponentAdapter() { + override fun focusLost(e: FocusEvent) = materialiseRendererAt(hoveredIndex) + override fun focusGained(e: FocusEvent) = materialiseRendererAt(hoveredIndex) + override fun valueChanged(e: ListSelectionEvent) = materialiseRendererAt(hoveredIndex) + override fun componentResized(e: ComponentEvent) = materialiseRendererAt(hoveredIndex) + } + + private fun materialiseRendererAt(index: Int) { + if (index < 0 || index > list.model.size - 1) { + rendererComponent?.let { list.remove(it) } + rendererComponent = null + return + } + + val cellValue = list.model.getElementAt(index) + val selected = list.isSelectedIndex(index) + val focused = list.hasFocus() && selected + + rendererComponent = cellRenderer.getListCellRendererComponent(list, cellValue, index, selected, focused).apply { + list.add(this) + + bounds = list.getCellBounds(index, index) + validate() + repaint() + } + } + + companion object { + + /** + * [cellRenderer] should be an instance different from one in the list + */ + fun install(list: JList, cellRenderer: ListCellRenderer): JListHoveredRowMaterialiser { + if (list.cellRenderer === cellRenderer + || (list.cellRenderer as? ExpandedItemListCellRendererWrapper)?.wrappee === cellRenderer) + error("cellRenderer should be an instance different from list cell renderer") + + return JListHoveredRowMaterialiser(list, cellRenderer).also { + with(list) { + addMouseMotionListener(it.listRowHoverListener) + addFocusListener(it.listPresentationListener) + addComponentListener(it.listPresentationListener) + addListSelectionListener(it.listPresentationListener) + model.addListDataListener(it.listDataListener) + } + } + } + } +} \ No newline at end of file