feat: terminal preview

This commit is contained in:
hstyi
2025-07-17 10:09:50 +08:00
committed by hstyi
parent ca64880a01
commit 6bb9a33a04
3 changed files with 239 additions and 19 deletions

View File

@@ -67,6 +67,8 @@ class TerminalDisplay(
} }
override fun paint(g: Graphics) { override fun paint(g: Graphics) {
if (isShowing.not()) return
if (g is Graphics2D) { if (g is Graphics2D) {
setupAntialiasing(g) setupAntialiasing(g)
clear(g) clear(g)
@@ -238,15 +240,27 @@ class TerminalDisplay(
} }
private fun drawCharacters(g: Graphics2D) { private fun drawCharacters(g: Graphics2D) {
drawCharacters(
g = g,
verticalScrollOffset = terminal.getScrollingModel().getVerticalScrollOffset(),
rows = terminal.getTerminalModel().getRows(),
overflowBreak = false,
)
}
internal fun drawCharacters(
g: Graphics2D,
verticalScrollOffset: Int,
rows: Int,
overflowBreak: Boolean,
) {
val terminalModel = terminal.getTerminalModel() val terminalModel = terminal.getTerminalModel()
val reverseVideo = terminalModel.getData(DataKey.ReverseVideo, false) val reverseVideo = terminalModel.getData(DataKey.ReverseVideo, false)
val rows = terminalModel.getRows()
val cols = terminalModel.getCols() val cols = terminalModel.getCols()
val triple = Triple(Char.Space.toString(), TextStyle.Default, 1) val triple = Triple(Char.Space.toString(), TextStyle.Default, 1)
val cursorPosition = terminal.getCursorModel().getPosition() val cursorPosition = terminal.getCursorModel().getPosition()
val averageCharWidth = getAverageCharWidth() val averageCharWidth = getAverageCharWidth()
val maxVerticalScrollOffset = terminal.getScrollingModel().getMaxVerticalScrollOffset() val maxVerticalScrollOffset = terminal.getScrollingModel().getMaxVerticalScrollOffset()
val verticalScrollOffset = terminal.getScrollingModel().getVerticalScrollOffset()
val selectionModel = terminal.getSelectionModel() val selectionModel = terminal.getSelectionModel()
val cursorStyle = terminalModel.getData(DataKey.CursorStyle) val cursorStyle = terminalModel.getData(DataKey.CursorStyle)
val showCursor = terminalModel.getData(DataKey.ShowCursor) val showCursor = terminalModel.getData(DataKey.ShowCursor)
@@ -255,11 +269,13 @@ class TerminalDisplay(
val blink = terminalBlink.blink val blink = terminalBlink.blink
val cursorBlink = terminalBlink.cursorBlink val cursorBlink = terminalBlink.cursorBlink
val hasFocus = terminalModel.getData(TerminalPanel.Focused, false) val hasFocus = terminalModel.getData(TerminalPanel.Focused, false)
val lineCount = terminal.getDocument().getLineCount()
for (i in 1..rows) { for (i in 1..rows) {
var xOffset = 0 var xOffset = 0
val row = verticalScrollOffset + i - 1 val row = verticalScrollOffset + i - 1
// 超出总行数则熔断,避免扩容情况出现
if (overflowBreak && row >= lineCount) break
val characters = smartCharacters(row).iterator() val characters = smartCharacters(row).iterator()
var j = 1 var j = 1
while (j <= cols) { while (j <= cols) {

View File

@@ -1,8 +1,6 @@
package app.termora.terminal.panel package app.termora.terminal.panel
import app.termora.Disposable import app.termora.*
import app.termora.Disposer
import app.termora.TerminalTab
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
@@ -10,6 +8,7 @@ import app.termora.database.DatabaseManager
import app.termora.plugin.internal.ssh.SSHTerminalTab import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.terminal.* import app.termora.terminal.*
import app.termora.terminal.panel.vw.* import app.termora.terminal.panel.vw.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.ArrayUtils import org.apache.commons.lang3.ArrayUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
@@ -27,11 +26,11 @@ import java.text.AttributedCharacterIterator
import java.text.AttributedString import java.text.AttributedString
import java.text.BreakIterator import java.text.BreakIterator
import java.text.CharacterIterator import java.text.CharacterIterator
import javax.swing.JLayeredPane import javax.swing.*
import javax.swing.JPanel
import javax.swing.SwingUtilities
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max import kotlin.math.max
import kotlin.math.min
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -47,6 +46,17 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
val FocusMode = DataKey(Boolean::class) val FocusMode = DataKey(Boolean::class)
} }
/**
* 内边距
*/
var padding = Insets(4, 4, 4, 4)
set(value) {
field = value
repaintImmediate()
}
private val disposable = Disposer.newDisposable()
private val myOwner get() = SwingUtilities.getWindowAncestor(this)
private val properties get() = DatabaseManager.getInstance().properties private val properties get() = DatabaseManager.getInstance().properties
private val terminalBlink = TerminalBlink(terminal) private val terminalBlink = TerminalBlink(terminal)
private val terminalFindPanel = TerminalFindPanel(this, terminal) private val terminalFindPanel = TerminalFindPanel(this, terminal)
@@ -54,6 +64,7 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
private val terminalDisplay = TerminalDisplay(this, terminal, terminalBlink) private val terminalDisplay = TerminalDisplay(this, terminal, terminalBlink)
private val layeredPane = TerminalLayeredPane() private val layeredPane = TerminalLayeredPane()
private var visualWindows = emptyArray<VisualWindow>() private var visualWindows = emptyArray<VisualWindow>()
private val terminalPreviewDialog = TerminalPreviewDialog()
val scrollBar = TerminalScrollBar(this, terminalFindPanel, terminal) val scrollBar = TerminalScrollBar(this, terminalFindPanel, terminal)
var enableFloatingToolbar = true var enableFloatingToolbar = true
@@ -95,14 +106,6 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
*/ */
var resizeToast = true var resizeToast = true
/**
* 内边距
*/
var padding = Insets(4, 4, 4, 4)
set(value) {
field = value
repaintImmediate()
}
/** /**
* Toast 总开关 * Toast 总开关
@@ -166,6 +169,7 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
override fun focusLost(e: FocusEvent) { override fun focusLost(e: FocusEvent) {
terminal.getTerminalModel().setData(Focused, false) terminal.getTerminalModel().setData(Focused, false)
repaintImmediate() repaintImmediate()
hidePreview()
} }
override fun focusGained(e: FocusEvent) { override fun focusGained(e: FocusEvent) {
@@ -197,7 +201,11 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
// 滚动相关 // 滚动相关
this.addMouseWheelListener(object : MouseWheelListener { this.addMouseWheelListener(object : MouseWheelListener {
override fun mouseWheelMoved(e: MouseWheelEvent) { override fun mouseWheelMoved(e: MouseWheelEvent) {
if (!terminal.getScrollingModel().canVerticalScroll()) { if (terminal.getScrollingModel().canVerticalScroll().not()) {
return
}
if (isShowingPreview()) {
return return
} }
@@ -236,6 +244,7 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
// 监听悬浮工具栏变化,然后重新渲染 // 监听悬浮工具栏变化,然后重新渲染
floatingToolbar.addPropertyChangeListener { repaintImmediate() } floatingToolbar.addPropertyChangeListener { repaintImmediate() }
Disposer.register(this, disposable)
} }
private fun enableDropTarget() { private fun enableDropTarget() {
@@ -416,6 +425,7 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
} }
override fun dispose() { override fun dispose() {
terminalPreviewDialog.dispose()
Disposer.dispose(terminalBlink) Disposer.dispose(terminalBlink)
Disposer.dispose(floatingToolbar) Disposer.dispose(floatingToolbar)
} }
@@ -494,6 +504,64 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
super.paint(g) super.paint(g)
} }
internal fun showPreview(location: Point) {
if (terminal.getScrollingModel().canVerticalScroll().not()) return
val lineHeight = terminalDisplay.getLineHeight()
val size = Dimension(
terminalDisplay.width + min((terminalDisplay.width * 0.1).toInt(), 50) + (padding.left + padding.right),
lineHeight * 10 + padding.top + padding.bottom * if (SystemInfo.isWindows_11_orLater) 2 else 1
)
val myLocation = Point(
scrollBar.locationOnScreen.x - (scrollBar.width / 2) - size.width,
location.y - lineHeight * 2
)
// 如果超过了滚动条位置,那么使用最安全的大小
if (abs(myLocation.x) + size.width > scrollBar.locationOnScreen.x) {
size.width = terminalDisplay.width
myLocation.x = scrollBar.locationOnScreen.x - (scrollBar.width / 2) - size.width
}
// 如果超出了屏幕底部边界,那么修改弹窗位置
val rectangle = getUsableDeviceBounds(terminalPreviewDialog.graphicsConfiguration)
if (abs(myLocation.y) + size.height > rectangle.y + rectangle.height) {
myLocation.y = location.y + lineHeight * 2 - size.height
}
val point = Point(location)
SwingUtilities.convertPointFromScreen(point, scrollBar)
val scrollRatio = 1.0 * point.y / scrollBar.height
val count = max(terminal.getDocument().getLineCount(), terminal.getTerminalModel().getRows())
val totalHeight = count * lineHeight
val scrollTop = scrollRatio * totalHeight
val rowIndex = floor(scrollTop / lineHeight).toInt()
terminalPreviewDialog.row = rowIndex
terminalPreviewDialog.size = size
terminalPreviewDialog.location = myLocation
terminalPreviewDialog.isVisible = true
}
private fun getUsableDeviceBounds(gc: GraphicsConfiguration): Rectangle {
val bounds = gc.bounds
val insets = Toolkit.getDefaultToolkit().getScreenInsets(gc)
bounds.x += insets.left
bounds.y += insets.top
bounds.width -= (insets.left + insets.right)
bounds.height -= (insets.top + insets.bottom)
return bounds
}
internal fun hidePreview() {
terminalPreviewDialog.isVisible = false
}
internal fun isShowingPreview() = terminalPreviewDialog.isVisible
private inner class TerminalLayeredPane : JLayeredPane() { private inner class TerminalLayeredPane : JLayeredPane() {
override fun doLayout() { override fun doLayout() {
val averageCharWidth = getAverageCharWidth() val averageCharWidth = getAverageCharWidth()
@@ -556,6 +624,82 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w
} }
} }
private inner class TerminalPreviewDialog : JDialog() {
private val terminalPreviewPanel = TerminalPreviewPanel()
var row
get() = terminalPreviewPanel.row
set(value) {
terminalPreviewPanel.row = value
}
init {
initView()
}
override fun setVisible(b: Boolean) {
if (b) terminalPreviewPanel.repaint()
super.setVisible(b)
}
private fun initView() {
isAlwaysOnTop = true
focusableWindowState = false
defaultCloseOperation = DISPOSE_ON_CLOSE
if (SystemInfo.isMacOS) {
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
} else {
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, true)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, 0)
}
val panel = JPanel(BorderLayout())
panel.border = BorderFactory.createEmptyBorder(
padding.top, padding.left,
padding.bottom, padding.right
)
panel.add(terminalPreviewPanel, BorderLayout.CENTER)
rootPane.contentPane = panel
}
override fun addNotify() {
super.addNotify()
if (SystemInfo.isMacOS) {
NativeMacLibrary.setControlsVisible(this, false)
} else {
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICONIFFY, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_MAXIMIZE, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, false)
}
}
}
private inner class TerminalPreviewPanel : JComponent() {
var row = 0
private val lineCount get() = terminal.getDocument().getLineCount()
private val rows = 10
override fun paint(g: Graphics) {
if (g !is Graphics2D) return
var row = this.row
if (row + rows > lineCount) row = row - (row + rows - lineCount)
g.save()
setupAntialiasing(g)
terminalDisplay.drawCharacters(g, verticalScrollOffset = max(0, row), rows = rows, overflowBreak = true)
g.restore()
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? { override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalTab) { if (dataKey == DataProviders.TerminalTab) {

View File

@@ -1,16 +1,26 @@
package app.termora.terminal.panel package app.termora.terminal.panel
import app.termora.swingCoroutineScope
import app.termora.terminal.Terminal import app.termora.terminal.Terminal
import app.termora.terminal.TerminalColor import app.termora.terminal.TerminalColor
import com.formdev.flatlaf.ui.FlatScrollBarUI import com.formdev.flatlaf.ui.FlatScrollBarUI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import java.awt.Color import java.awt.Color
import java.awt.Graphics import java.awt.Graphics
import java.awt.Rectangle import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.JScrollBar import javax.swing.JScrollBar
import javax.swing.SwingUtilities
import javax.swing.plaf.ScrollBarUI import javax.swing.plaf.ScrollBarUI
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.max import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
class TerminalScrollBar( class TerminalScrollBar(
private val terminalPanel: TerminalPanel, private val terminalPanel: TerminalPanel,
@@ -19,15 +29,24 @@ class TerminalScrollBar(
) : JScrollBar() { ) : JScrollBar() {
private val colorPalette get() = terminal.getTerminalModel().getColorPalette() private val colorPalette get() = terminal.getTerminalModel().getColorPalette()
private val myUI = MyScrollBarUI() private val myUI = MyScrollBarUI()
private val owner get() = SwingUtilities.getWindowAncestor(this)
init { init {
setUI(myUI) setUI(myUI)
initEvents()
} }
override fun setUI(ui: ScrollBarUI) { override fun setUI(ui: ScrollBarUI) {
super.setUI(myUI) super.setUI(myUI)
} }
private fun initEvents() {
val previewMouseAdapter = PreviewMouseAdapter()
addMouseMotionListener(previewMouseAdapter)
addMouseListener(previewMouseAdapter)
}
private fun drawFindMap(g: Graphics, trackBounds: Rectangle) { private fun drawFindMap(g: Graphics, trackBounds: Rectangle) {
if (!terminalPanel.findMap) return if (!terminalPanel.findMap) return
val kinds = terminalFindPanel.kinds val kinds = terminalFindPanel.kinds
@@ -53,13 +72,54 @@ class TerminalScrollBar(
val y = max(ceil(trackBounds.height * n).toInt() - lineHeight, 0) val y = max(ceil(trackBounds.height * n).toInt() - lineHeight, 0)
g.fillRect(trackBounds.width - averageCharWidth, y, averageCharWidth, lineHeight) g.fillRect(trackBounds.width - averageCharWidth, y, averageCharWidth, lineHeight)
} }
} }
private inner class MyScrollBarUI : FlatScrollBarUI() { private inner class MyScrollBarUI : FlatScrollBarUI() {
override fun paintTrack(g: Graphics, c: JComponent, trackBounds: Rectangle) { override fun paintTrack(g: Graphics, c: JComponent, trackBounds: Rectangle) {
super.paintTrack(g, c, trackBounds) super.paintTrack(g, c, trackBounds)
drawFindMap(g, trackBounds) drawFindMap(g, trackBounds)
} }
public override fun getThumbBounds(): Rectangle {
return super.getThumbBounds()
}
}
private inner class PreviewMouseAdapter : MouseAdapter() {
private var job: Job? = null
override fun mouseMoved(e: MouseEvent) {
if (terminal.getScrollingModel().canVerticalScroll().not()) {
mouseExited(e)
return
}
if (myUI.thumbBounds.contains(e.point)) {
mouseExited(e)
return
}
if (terminalPanel.isShowingPreview()) {
doMouseMoved(e)
} else {
job?.cancel()
job = swingCoroutineScope.launch(Dispatchers.Swing) {
delay(250.milliseconds)
doMouseMoved(e)
}
}
}
private fun doMouseMoved(e: MouseEvent) {
if (owner.isFocused.not()) return
terminalPanel.showPreview(e.locationOnScreen)
}
override fun mouseExited(e: MouseEvent) {
job?.cancel()
terminalPanel.hidePreview()
}
} }
} }