DBE-15813: Refactor ErrorNotificationPanel class to use native Swing components instead of HTML

GitOrigin-RevId: 787bb629fdbefbfeb3f675356d1be2988160a3fe
This commit is contained in:
Ekaterina Chernikova
2025-03-03 15:30:21 +01:00
committed by intellij-monorepo-bot
parent cd39f261dd
commit 177092311d
4 changed files with 213 additions and 203 deletions

View File

@@ -20,6 +20,7 @@ action.full.message.text=Full Message
action.close.text=Close action.close.text=Close
tooltip.close.esc=Close (Esc) tooltip.close.esc=Close (Esc)
dialog.title.query.error=Query Error dialog.title.query.error=Query Error
dialog.button.copy.and.close=Copy and close
notification.content.unknown.problem.occurred.see.details=An unknown problem occurred (see Details) notification.content.unknown.problem.occurred.see.details=An unknown problem occurred (see Details)
Console.TableResult.n.bytes.of.m.bytes.loaded={0} of {1} Console.TableResult.n.bytes.of.m.bytes.loaded={0} of {1}
action.Console.TableResult.ClearCell.text=Clear Field action.Console.TableResult.ClearCell.text=Clear Field

View File

@@ -14,6 +14,7 @@ public interface ErrorInfo extends ThrowableInfo {
interface Fix { interface Fix {
@Nls @Nls
String getName(); String getName();
default Integer getMnemonic() { return null; }
default boolean isSilent() { default boolean isSilent() {
return true; return true;
} }

View File

@@ -2,227 +2,200 @@ package com.intellij.database.run.ui
import com.intellij.CommonBundle import com.intellij.CommonBundle
import com.intellij.database.DataGridBundle import com.intellij.database.DataGridBundle
import com.intellij.database.datagrid.GridUtil
import com.intellij.icons.AllIcons import com.intellij.icons.AllIcons
import com.intellij.ide.CopyProvider import com.intellij.ide.CopyProvider
import com.intellij.ide.IdeTooltipManager.Companion.getInstance
import com.intellij.ide.IdeTooltipManager.Companion.initPane
import com.intellij.ide.TextCopyProvider import com.intellij.ide.TextCopyProvider
import com.intellij.openapi.Disposable import com.intellij.openapi.MnemonicHelper
import com.intellij.openapi.actionSystem.* import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.actionSystem.UiDataProvider.Companion.wrapComponent import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.ui.MessageType import com.intellij.openapi.ui.MessageType
import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.* import com.intellij.openapi.util.NlsActions
import com.intellij.openapi.util.registry.Registry.Companion.`is` import com.intellij.openapi.util.NlsContexts
import com.intellij.openapi.util.text.StringUtil import com.intellij.openapi.util.text.StringUtil
import com.intellij.ui.HintHint import com.intellij.util.ui.JBDimension
import com.intellij.ui.HyperlinkAdapter
import com.intellij.util.Consumer
import com.intellij.util.ObjectUtils
import com.intellij.util.containers.ContainerUtil
import com.intellij.util.ui.JBInsets
import com.intellij.util.ui.JBUI import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.NonNls import java.awt.*
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Dimension
import java.awt.event.HierarchyEvent
import java.awt.event.HierarchyListener
import java.awt.event.InputEvent
import java.awt.event.KeyEvent import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
import java.awt.event.MouseListener
import java.awt.font.TextAttribute
import javax.swing.* import javax.swing.*
import javax.swing.event.HyperlinkEvent
private const val HORIZONTAL_LAYOUT_THRESHOLD = 2
private const val VERTICAL_MARGINS = 10
private const val HEIGHT_BETWEEN_TEXT_AND_BUTTONS = 6
private const val HORIZONTAL_MARGINS = 16
class ErrorNotificationPanel private constructor( class ErrorNotificationPanel private constructor(
htmlErrorMessage: @NlsContexts.NotificationContent String, htmlErrorMessage: String?,
private val myActions: MutableMap<String?, Runnable?>, items: List<PanelItem>,
private val myType: MessageType private val hideErrorAction: Runnable?,
) : JPanel(BorderLayout()) { private val messageType: MessageType = MessageType.ERROR,
private val myMessagePane: JEditorPane ) : JPanel(BorderLayout()), UiDataProvider {
private val myCopyProvider: CopyProvider private var copyProvider: CopyProvider? = null
private val textPane: JTextArea?
init { init {
setBorder(JBUI.Borders.empty(0, 4)) background = messageType.popupBackground
isFocusable = true
isFocusCycleRoot = true
border = JBUI.Borders.empty(VERTICAL_MARGINS, 0)
isRequestFocusEnabled = true
myMessagePane = initPane(htmlErrorMessage, HintHint() val horizontalLayout = items.size <= HORIZONTAL_LAYOUT_THRESHOLD
.setAwtTooltip(false) textPane = htmlErrorMessage?.let { createTextPanel(htmlErrorMessage, horizontalLayout) }
.setTextFg(getForeground()) initComponent(items, horizontalLayout)
.setTextBg(getBackground())
.setBorderColor(getBackground()) SwingUtilities.invokeLater { requestFocusInWindow() }
.setBorderInsets(JBInsets.emptyInsets()), null)
myMessagePane.setBorder(null)
myMessagePane.addHyperlinkListener(object : HyperlinkAdapter() {
override fun hyperlinkActivated(e: HyperlinkEvent) {
performAction(e.description)
}
})
myCopyProvider = object : TextCopyProvider() {
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.EDT
} }
override fun getTextLinesToCopy(): MutableCollection<String?>? { private fun initComponent(items: List<PanelItem>, horizontalLayout: Boolean) {
val text = myMessagePane.getSelectedText() val buttonsGravity = if (horizontalLayout) { BorderLayout.EAST } else { BorderLayout.NORTH }
return if (StringUtil.isEmpty(text)) null else mutableSetOf<String?>(text) val buttonPanel = JPanel(FlowLayout(FlowLayout.RIGHT, HORIZONTAL_MARGINS, 0)).apply {
items.forEach { add(it.buildComponent()) }
isOpaque = false
border = JBUI.Borders.empty()
} }
}
add(wrapComponent(myMessagePane, UiDataProvider { sink: DataSink? ->
sink!!.set<CopyProvider>(PlatformDataKeys.COPY_PROVIDER, this.myCopyProvider)
}), BorderLayout.CENTER)
add(buttonPanel, buttonsGravity)
textPane?.let { add(it, BorderLayout.CENTER) }
addSubscribers(textPane)
addMouseListener(RequestFocusMouseListener(this))
textPane?.addMouseListener(RequestFocusMouseListener(this))
}
private fun createTextPanel(htmlText: String, horizontalLayout: Boolean) = JTextArea().apply {
isOpaque = false
isEditable = false
isFocusable = true
text = htmlText.trimIndent()
font = UIManager.getFont("ToolTip.font")
lineWrap = true
border = if (horizontalLayout) {
JBUI.Borders.empty(0, VERTICAL_MARGINS, 0, 0)
} else {
JBUI.Borders.empty(HEIGHT_BETWEEN_TEXT_AND_BUTTONS, HORIZONTAL_MARGINS, 0, HORIZONTAL_MARGINS)
}
wrapStyleWord = true
foreground = messageType.titleForeground
caret = object : javax.swing.text.DefaultCaret() {
override fun paint(g: Graphics?) { /* hide cursor */ }
}
}
override fun addMouseListener(listener: MouseListener) {
super.addMouseListener(listener)
textPane?.addMouseListener(listener)
}
private fun addSubscribers(textPane: JTextArea?) {
object : DumbAwareAction(DataGridBundle.message("action.close.text")) { object : DumbAwareAction(DataGridBundle.message("action.close.text")) {
override fun actionPerformed(e: AnActionEvent) { override fun actionPerformed(e: AnActionEvent) {
performAction(DataGridBundle.message("action.close.text")) hideErrorAction?.run()
} }
}.registerCustomShortcutSet(CustomShortcutSet(KeyEvent.VK_ESCAPE), this) }.registerCustomShortcutSet(CustomShortcutSet(KeyEvent.VK_ESCAPE), this)
copyProvider = object : TextCopyProvider() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun getTextLinesToCopy(): MutableCollection<String?>? {
textPane ?: return null
textPane.selectedText?.let { return mutableSetOf(it) }
return mutableSetOf(textPane.text)
}
}
} }
private fun performAction(actionName: String?) { override fun getMinimumSize(): Dimension = JBUI.emptySize()
myActions[actionName]?.run()
override fun uiDataSnapshot(sink: DataSink) {
sink[PlatformDataKeys.COPY_PROVIDER] = copyProvider
} }
override fun getBackground(): Color {
return type.popupBackground
}
override fun getForeground(): Color {
return type.titleForeground
}
private val type: MessageType
get() = ObjectUtils.chooseNotNull<MessageType>(myType, MessageType.ERROR)
override fun getMinimumSize(): Dimension {
return JBUI.emptySize()
}
val content: JComponent
get() = myMessagePane
class Builder internal constructor( class Builder internal constructor(
private val myMessage: @NlsContexts.NotificationContent String?, private val message: @NlsContexts.NotificationContent String?,
private val myError: Throwable?, private val error: Throwable?,
private val myBaseComponent: JComponent
) { ) {
private val myLongMessage: Boolean private var errorMessage: String? = null
private val items = mutableListOf<PanelItem>()
private val myActions: MutableMap<String?, Runnable?> = LinkedHashMap<String?, Runnable?>() private var type: MessageType = MessageType.ERROR
private val myShowHideHandlers: MutableList<Consumer<Disposable?>> = ArrayList<Consumer<Disposable?>>() private var hideErrorAction: Runnable? = null
private val myHtmlBuilder = StringBuilder()
private var isChoppedMessage = false private var isChoppedMessage = false
private var myType: MessageType = MessageType.ERROR
init { init {
var errorMessage = if (myMessage == null) if (myError == null) null else getNormalizedMessage(myError) errorMessage = when {
else getNormalized( message != null -> getNormalized(message)
myMessage) error != null -> getNormalizedMessage(error)
val font = getInstance().getTextFont(true) else -> null
val fm = myBaseComponent.getFontMetrics(font)
myLongMessage = SwingUtilities.computeStringWidth(fm, errorMessage) > myBaseComponent.getWidth() * 3 / 4
if (errorMessage != null) {
errorMessage = StringUtil.escapeXmlEntities(errorMessage).replace("\n", "<br>")
} }
myHtmlBuilder.append("<html><head><style type=\"text/css\">a:link {text-decoration:none;}</style></head><body>")
myHtmlBuilder.append("<font face=\"verdana\"><table width=\"100%\"><tr valign=\"top\"><td>")
myHtmlBuilder.append(errorMessage)
myHtmlBuilder.append("</td>")
} }
fun messageType(type: MessageType): Builder { fun messageType(type: MessageType): Builder {
myType = type this.type = type
return this return this
} }
fun addIconLink(command: String?, tooltipText: @NlsContexts.Tooltip String?, realIcon: Icon, action: Runnable?): Builder { fun addIconLink(tooltipText: @NlsContexts.Tooltip String?, realIcon: Icon, action: Runnable?): Builder {
val iconPath = GridUtil.getIconPath(realIcon) items.add(IconLink(realIcon, { action?.run() }, tooltipText))
startActionColumn()
myHtmlBuilder.append("<a href=\"")
.append(command).append("\"><icon alt=\"").append(tooltipText).append("\"")
.append("\" src=\"")
myHtmlBuilder.append(iconPath).append("\"></a>")
endActionColumn()
if (action != null) {
myActions.put(command, action)
}
return this return this
} }
fun addSpace(): Builder { fun addSpace(): Builder {
startActionColumn() items.add(EmptySpace())
endActionColumn()
return this return this
} }
fun addLink(command: @NonNls String, linkHtml: @NlsActions.ActionText String, action: Runnable): Builder { fun addLink(link: @NlsActions.ActionText String, mnemonicCode: Int? = null, action: Runnable): Builder {
startActionColumn() items.add(TextLink(link, { action.run() }, mnemonicCode))
val mnemonicIndex = UIUtil.getDisplayMnemonicIndex(command)
val fixedCommand: @NlsSafe String = if (mnemonicIndex < 0) command else command.substring(0, mnemonicIndex) + command.substring(mnemonicIndex + 1)
ContainerUtil.addIfNotNull<Consumer<Disposable?>?>(myShowHideHandlers,
createMnemonicActionIfNeeded(fixedCommand, mnemonicIndex, action, myBaseComponent))
myHtmlBuilder.append("<a style=\"text-decoration:none;\" href=\"").append(fixedCommand).append("\">").append(linkHtml).append("</a>")
endActionColumn()
myActions.put(fixedCommand, action)
return this return this
} }
fun addDetailsButton(): Builder { fun addDetailsButton(): Builder {
val message: String = (if (myError == null) myMessage val message: String? = when {
else if (myError.stackTrace.size > 0) com.intellij.util.ExceptionUtil.getThrowableText(myError, "com.intellij.") error == null -> message
else myError.message)!! error.stackTrace.size > 0 -> com.intellij.util.ExceptionUtil.getThrowableText(error, "com.intellij.")
if (StringUtil.contains(myHtmlBuilder, message)) return this else -> error.message
return addLink("details", DataGridBundle.message("action.details.text"), Runnable { }
Messages.showIdeaMessageDialog(null, message,
DataGridBundle.message("dialog.title.query.error"), return addLink(DataGridBundle.message("action.details.text")) {
Messages.showIdeaMessageDialog(
null, message, DataGridBundle.message("dialog.title.query.error"),
arrayOf<String>(CommonBundle.getOkButtonText()), 0, Messages.getErrorIcon(), null) arrayOf<String>(CommonBundle.getOkButtonText()), 0, Messages.getErrorIcon(), null)
}) }
} }
fun addFullMessageButtonIfNeeded(): Builder { fun addFullMessageButtonIfNeeded(): Builder {
if (!isChoppedMessage) return this if (!isChoppedMessage) return this
return addLink("details", DataGridBundle.message("action.full.message.text"), Runnable { return addLink(DataGridBundle.message("action.full.message.text"), KeyEvent.VK_F, Runnable {
Messages.showIdeaMessageDialog(null, myMessage, val result = Messages.showIdeaMessageDialog(
DataGridBundle.message("dialog.title.query.error"), null, message, DataGridBundle.message("dialog.title.query.error"),
arrayOf<String>(CommonBundle.getOkButtonText()), 0, Messages.getErrorIcon(), null arrayOf<String>(CommonBundle.getCancelButtonText(), DataGridBundle.message("dialog.button.copy.and.close")), 0, Messages.getErrorIcon(), null
) )
if (result == 1) {
message?.let { CopyPasteManager.copyTextToClipboard(message) }
}
}) })
} }
fun addCloseButton(action: Runnable?): Builder { fun addCloseButton(action: Runnable?): Builder {
return addIconLink(DataGridBundle.message("action.close.text"), DataGridBundle.message("tooltip.close.esc"), hideErrorAction = action
AllIcons.Actions.Close, action) return addIconLink(DataGridBundle.message("tooltip.close.esc"), AllIcons.Actions.Close, action)
} }
fun build(): ErrorNotificationPanel { fun build(): ErrorNotificationPanel {
myHtmlBuilder.append("</tr></table></font></body>") val result = ErrorNotificationPanel(errorMessage, items, hideErrorAction, this.type)
val result = ErrorNotificationPanel(myHtmlBuilder.toString(), myActions, myType) //NON-NLS
registerShowHideHandlers(result)
return result return result
} }
private fun startActionColumn() { @Suppress("HardCodedStringLiteral")
myHtmlBuilder.append("<td width=\"1%\" align=\"right\" valign=\"")
.append(if (myLongMessage) "top" else "middle")
.append("\" nowrap><div style='margin:0px 2px 0px 2px'>")
}
private fun endActionColumn() {
myHtmlBuilder.append("</div></td>")
}
private fun getNormalizedMessage(error: Throwable): @NlsContexts.NotificationContent String { private fun getNormalizedMessage(error: Throwable): @NlsContexts.NotificationContent String {
var sourceMessage = StringUtil.notNullize(error.message, var sourceMessage = StringUtil.notNullize(error.message, DataGridBundle.message(
DataGridBundle.message( "notification.content.unknown.problem.occurred.see.details"))
"notification.content.unknown.problem.occurred.see.details")) + "kgmsdkgmksdfgnmksndfgknskdfgnksndgkndfkgnsdkfgnskdngkndsfgksnkgnfksdngksdnfgksndkgnsdkfgnksdnfgkndkfgnskngfksnkgfnksdnfgksdnfgknsdkgnslgnskldfnglksnfgksnfgksnkfgnksdfngksdnfgksdngksndfgknsdkf" // In some cases a source message contains a stacktrace inside. Let's chop it
// In some cases source message contains stacktrace inside. Let's chop it
val divPos = sourceMessage.indexOf("\n\tat ") val divPos = sourceMessage.indexOf("\n\tat ")
if (divPos != -1) { if (divPos != -1) {
sourceMessage = sourceMessage.substring(0, divPos) sourceMessage = sourceMessage.substring(0, divPos)
@@ -238,59 +211,97 @@ class ErrorNotificationPanel private constructor(
if (sourceMessage.length > limit) isChoppedMessage = true if (sourceMessage.length > limit) isChoppedMessage = true
return StringUtil.trimLog(sourceMessage, limit + 1) return StringUtil.trimLog(sourceMessage, limit + 1)
} }
private fun registerShowHideHandlers(component: JComponent) {
if (myShowHideHandlers.isEmpty()) return
component.addHierarchyListener(object : HierarchyListener {
private var myShownDisposable: Disposable? = null
override fun hierarchyChanged(e: HierarchyEvent) {
val c = e.component
if (c == null || (e.getChangeFlags() and HierarchyEvent.SHOWING_CHANGED.toLong()) <= 0) return
if (c.isShowing()) {
myShownDisposable = Disposer.newDisposable()
for (handler in myShowHideHandlers) {
handler.consume(myShownDisposable)
}
return
} }
if (myShownDisposable != null) Disposer.dispose(myShownDisposable!!) private interface PanelItem {
myShownDisposable = null fun buildComponent(): JComponent
}
})
} }
companion object { private class TextLink(
private fun createMnemonicActionIfNeeded( @Nls private val linkText: String,
command: @NlsActions.ActionText String, private val onClickAction: () -> Unit,
mnemonicIndex: Int, private val mnemonicCode: Int? = null,
runnable: Runnable, ) : PanelItem {
component: JComponent? override fun buildComponent(): JComponent {
): Consumer<Disposable?>? { return JButton().apply {
if (mnemonicIndex < 0) return null foreground = JBUI.CurrentTheme.Link.Foreground.ENABLED
return { parentDisposable: Disposable? -> text = linkText
val a: DumbAwareAction = object : DumbAwareAction(command) { font = UIManager.getFont("ToolTip.font")
override fun actionPerformed(e: AnActionEvent) {
runnable.run() isOpaque = false
isContentAreaFilled = false
isBorderPainted = false
val fontMetrics = getFontMetrics(font)
preferredSize = JBDimension(fontMetrics.stringWidth(linkText), fontMetrics.height)
border = JBUI.Borders.empty()
isFocusable = true
if (mnemonicCode != null) {
mnemonic = mnemonicCode
addActionListener { onClickAction() }
MnemonicHelper.registerMnemonicAction(this, mnemonicCode)
}
addMouseListener(MouseClickedListener(onClickAction, this))
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
} }
} }
val modifiers = if (SystemInfo.isMac && !`is`( }
"ide.mac.alt.mnemonic.without.ctrl")
) InputEvent.ALT_MASK or InputEvent.CTRL_MASK private class IconLink(
else InputEvent.ALT_MASK private val icon: Icon,
val keyStroke = KeyStroke.getKeyStroke(command.get(mnemonicIndex).uppercaseChar().code, modifiers) private val onClickAction: () -> Unit,
a.registerCustomShortcutSet(CustomShortcutSet(keyStroke), component, parentDisposable) @Nls private val tooltipText: String? = null,
} as? Consumer<Disposable?> ) : PanelItem {
override fun buildComponent(): JComponent {
return JLabel(icon).apply {
toolTipText = tooltipText
addMouseListener(MouseClickedListener(onClickAction, this))
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
} }
} }
} }
private class EmptySpace() : PanelItem {
override fun buildComponent(): JComponent {
return Box.createRigidArea(JBDimension(1, 20)) as JComponent
}
}
private class MouseClickedListener(
private val onClickAction: () -> Unit,
private val component: JComponent,
) : MouseListener {
private val originalFont = component.font
private val underlinedFont = originalFont.deriveFont(mapOf(TextAttribute.UNDERLINE to TextAttribute.UNDERLINE_ON))
override fun mouseClicked(e: MouseEvent?) = onClickAction()
override fun mousePressed(e: MouseEvent?) = Unit
override fun mouseReleased(e: MouseEvent?) = Unit
override fun mouseEntered(e: MouseEvent?) {
component.font = underlinedFont
}
override fun mouseExited(e: MouseEvent?) {
component.font = originalFont
}
}
private class RequestFocusMouseListener(
private val component: JComponent,
) : MouseListener {
override fun mouseClicked(e: MouseEvent?) {
component.requestFocusInWindow()
}
override fun mousePressed(e: MouseEvent?) = Unit
override fun mouseReleased(e: MouseEvent?) = Unit
override fun mouseEntered(e: MouseEvent?) = Unit
override fun mouseExited(e: MouseEvent?) = Unit
}
companion object { companion object {
@JvmStatic @JvmStatic
fun create(message: @NlsContexts.NotificationContent String?, error: Throwable?, baseComponent: JComponent): Builder { fun create(message: @NlsContexts.NotificationContent String?, error: Throwable?): Builder {
return Builder(message, error, baseComponent) return Builder(message, error)
} }
} }
} }

View File

@@ -67,10 +67,7 @@ import org.jetbrains.annotations.Nullable;
import javax.swing.*; import javax.swing.*;
import java.awt.*; import java.awt.*;
import java.awt.event.ActionEvent; import java.awt.event.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.util.*; import java.util.*;
import java.util.List; import java.util.List;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
@@ -825,11 +822,11 @@ public class TableResultPanel extends UserDataHolderBase
void showError(@NotNull ErrorInfo errorInfo, final @Nullable DataGridRequestPlace source) { void showError(@NotNull ErrorInfo errorInfo, final @Nullable DataGridRequestPlace source) {
hideErrorPanel(); hideErrorPanel();
ErrorNotificationPanel.Builder builder = ErrorNotificationPanel.Builder builder =
ErrorNotificationPanel.create(errorInfo.getMessage(), errorInfo.getOriginalThrowable(), myMainPanel); ErrorNotificationPanel.create(errorInfo.getMessage(), errorInfo.getOriginalThrowable());
List<ErrorInfo.Fix> fixes = errorInfo.getFixes(); List<ErrorInfo.Fix> fixes = errorInfo.getFixes();
if (!fixes.isEmpty()) { if (!fixes.isEmpty()) {
for (ErrorInfo.Fix fix : fixes) { for (ErrorInfo.Fix fix : fixes) {
builder.addLink(fix.getName(), fix.getName(), () -> GridHelper.get(this).applyFix(myProject, fix, null)); builder.addLink(fix.getName(), null, () -> GridHelper.get(this).applyFix(myProject, fix, null));
} }
builder.addSpace(); builder.addSpace();
} }
@@ -845,7 +842,7 @@ public class TableResultPanel extends UserDataHolderBase
int c = viewColumnIdx.asInteger() + 1; int c = viewColumnIdx.asInteger() + 1;
//noinspection DialogTitleCapitalization //noinspection DialogTitleCapitalization
String title = DataGridBundle.message("action.row.choice.col.text", r, c, c < 1 ? 0 : 1); String title = DataGridBundle.message("action.row.choice.col.text", r, c, c < 1 ? 0 : 1);
builder.addLink("navigate", title, () -> { builder.addLink(title, KeyEvent.VK_N, () -> {
if (viewRowIdx.isValid(this) && viewColumnIdx.isValid(this)) { if (viewRowIdx.isValid(this) && viewColumnIdx.isValid(this)) {
scrollToLocally(this, viewRowIdx, viewColumnIdx); scrollToLocally(this, viewRowIdx, viewColumnIdx);
} }