From 6bb9a33a04e977259d099f791adde296eb228086 Mon Sep 17 00:00:00 2001 From: hstyi Date: Thu, 17 Jul 2025 10:09:50 +0800 Subject: [PATCH] feat: terminal preview --- .../termora/terminal/panel/TerminalDisplay.kt | 22 ++- .../termora/terminal/panel/TerminalPanel.kt | 174 ++++++++++++++++-- .../terminal/panel/TerminalScrollBar.kt | 62 ++++++- 3 files changed, 239 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt index 0a6c186..b987d1c 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt @@ -67,6 +67,8 @@ class TerminalDisplay( } override fun paint(g: Graphics) { + if (isShowing.not()) return + if (g is Graphics2D) { setupAntialiasing(g) clear(g) @@ -238,15 +240,27 @@ class TerminalDisplay( } 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 reverseVideo = terminalModel.getData(DataKey.ReverseVideo, false) - val rows = terminalModel.getRows() val cols = terminalModel.getCols() val triple = Triple(Char.Space.toString(), TextStyle.Default, 1) val cursorPosition = terminal.getCursorModel().getPosition() val averageCharWidth = getAverageCharWidth() val maxVerticalScrollOffset = terminal.getScrollingModel().getMaxVerticalScrollOffset() - val verticalScrollOffset = terminal.getScrollingModel().getVerticalScrollOffset() val selectionModel = terminal.getSelectionModel() val cursorStyle = terminalModel.getData(DataKey.CursorStyle) val showCursor = terminalModel.getData(DataKey.ShowCursor) @@ -255,11 +269,13 @@ class TerminalDisplay( val blink = terminalBlink.blink val cursorBlink = terminalBlink.cursorBlink val hasFocus = terminalModel.getData(TerminalPanel.Focused, false) - + val lineCount = terminal.getDocument().getLineCount() for (i in 1..rows) { var xOffset = 0 val row = verticalScrollOffset + i - 1 + // 超出总行数则熔断,避免扩容情况出现 + if (overflowBreak && row >= lineCount) break val characters = smartCharacters(row).iterator() var j = 1 while (j <= cols) { diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt index 337ce44..5155119 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt @@ -1,8 +1,6 @@ package app.termora.terminal.panel -import app.termora.Disposable -import app.termora.Disposer -import app.termora.TerminalTab +import app.termora.* import app.termora.actions.DataProvider import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviders @@ -10,6 +8,7 @@ import app.termora.database.DatabaseManager import app.termora.plugin.internal.ssh.SSHTerminalTab import app.termora.terminal.* import app.termora.terminal.panel.vw.* +import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.util.SystemInfo import org.apache.commons.lang3.ArrayUtils import org.apache.commons.lang3.StringUtils @@ -27,11 +26,11 @@ import java.text.AttributedCharacterIterator import java.text.AttributedString import java.text.BreakIterator import java.text.CharacterIterator -import javax.swing.JLayeredPane -import javax.swing.JPanel -import javax.swing.SwingUtilities +import javax.swing.* import kotlin.math.abs +import kotlin.math.floor import kotlin.math.max +import kotlin.math.min import kotlin.time.Duration 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) } + /** + * 内边距 + */ + 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 terminalBlink = TerminalBlink(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 layeredPane = TerminalLayeredPane() private var visualWindows = emptyArray() + private val terminalPreviewDialog = TerminalPreviewDialog() val scrollBar = TerminalScrollBar(this, terminalFindPanel, terminal) var enableFloatingToolbar = true @@ -95,14 +106,6 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w */ var resizeToast = true - /** - * 内边距 - */ - var padding = Insets(4, 4, 4, 4) - set(value) { - field = value - repaintImmediate() - } /** * Toast 总开关 @@ -166,6 +169,7 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w override fun focusLost(e: FocusEvent) { terminal.getTerminalModel().setData(Focused, false) repaintImmediate() + hidePreview() } override fun focusGained(e: FocusEvent) { @@ -197,7 +201,11 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w // 滚动相关 this.addMouseWheelListener(object : MouseWheelListener { override fun mouseWheelMoved(e: MouseWheelEvent) { - if (!terminal.getScrollingModel().canVerticalScroll()) { + if (terminal.getScrollingModel().canVerticalScroll().not()) { + return + } + + if (isShowingPreview()) { return } @@ -236,6 +244,7 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w // 监听悬浮工具栏变化,然后重新渲染 floatingToolbar.addPropertyChangeListener { repaintImmediate() } + Disposer.register(this, disposable) } private fun enableDropTarget() { @@ -416,6 +425,7 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w } override fun dispose() { + terminalPreviewDialog.dispose() Disposer.dispose(terminalBlink) Disposer.dispose(floatingToolbar) } @@ -494,6 +504,64 @@ class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val w 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() { override fun doLayout() { 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") override fun getData(dataKey: DataKey): T? { if (dataKey == DataProviders.TerminalTab) { diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalScrollBar.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalScrollBar.kt index 583b32f..0cb613b 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalScrollBar.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalScrollBar.kt @@ -1,16 +1,26 @@ package app.termora.terminal.panel +import app.termora.swingCoroutineScope import app.termora.terminal.Terminal import app.termora.terminal.TerminalColor 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.Graphics import java.awt.Rectangle +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.JScrollBar +import javax.swing.SwingUtilities import javax.swing.plaf.ScrollBarUI import kotlin.math.ceil import kotlin.math.max +import kotlin.time.Duration.Companion.milliseconds class TerminalScrollBar( private val terminalPanel: TerminalPanel, @@ -19,15 +29,24 @@ class TerminalScrollBar( ) : JScrollBar() { private val colorPalette get() = terminal.getTerminalModel().getColorPalette() private val myUI = MyScrollBarUI() + private val owner get() = SwingUtilities.getWindowAncestor(this) + init { setUI(myUI) + initEvents() } override fun setUI(ui: ScrollBarUI) { super.setUI(myUI) } + private fun initEvents() { + val previewMouseAdapter = PreviewMouseAdapter() + addMouseMotionListener(previewMouseAdapter) + addMouseListener(previewMouseAdapter) + } + private fun drawFindMap(g: Graphics, trackBounds: Rectangle) { if (!terminalPanel.findMap) return val kinds = terminalFindPanel.kinds @@ -53,13 +72,54 @@ class TerminalScrollBar( val y = max(ceil(trackBounds.height * n).toInt() - lineHeight, 0) g.fillRect(trackBounds.width - averageCharWidth, y, averageCharWidth, lineHeight) } - } + private inner class MyScrollBarUI : FlatScrollBarUI() { override fun paintTrack(g: Graphics, c: JComponent, trackBounds: Rectangle) { super.paintTrack(g, c, 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() + } + } } \ No newline at end of file