From accf590c175cf0d6bbdb9a54e5105dd5f8cb9b99 Mon Sep 17 00:00:00 2001 From: hstyi Date: Fri, 4 Jul 2025 12:19:56 +0800 Subject: [PATCH] feat: support fallback font --- build.gradle.kts | 1 + src/main/kotlin/app/termora/FontComboBox.kt | 71 ++++++++++++ .../kotlin/app/termora/SettingsOptionsPane.kt | 72 ++++-------- .../app/termora/database/DatabaseManager.kt | 5 + .../termora/terminal/panel/TerminalDisplay.kt | 109 +++++++++++++++--- src/main/resources/i18n/messages.properties | 1 + .../resources/i18n/messages_zh_CN.properties | 1 + .../resources/i18n/messages_zh_TW.properties | 1 + 8 files changed, 194 insertions(+), 67 deletions(-) create mode 100644 src/main/kotlin/app/termora/FontComboBox.kt diff --git a/build.gradle.kts b/build.gradle.kts index b57711c..324d17d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -437,6 +437,7 @@ tasks.register("jpackage") { // NSWindow options.add("-Dapple.awt.application.appearance=system") options.add("--add-opens java.desktop/java.awt=ALL-UNNAMED") + options.add("--add-opens java.desktop/sun.font=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") diff --git a/src/main/kotlin/app/termora/FontComboBox.kt b/src/main/kotlin/app/termora/FontComboBox.kt new file mode 100644 index 0000000..98c6109 --- /dev/null +++ b/src/main/kotlin/app/termora/FontComboBox.kt @@ -0,0 +1,71 @@ +package app.termora + +import com.formdev.flatlaf.extras.components.FlatComboBox +import com.formdev.flatlaf.util.FontUtils +import java.awt.Component +import java.awt.Dimension +import javax.swing.DefaultListCellRenderer +import javax.swing.JList +import javax.swing.event.PopupMenuEvent +import javax.swing.event.PopupMenuListener + +class FontComboBox : FlatComboBox() { + private var fontsLoaded = false + + init { + val fontComboBox = this + fontComboBox.renderer = object : DefaultListCellRenderer() { + init { + preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2) + maximumSize = Dimension(preferredSize.width, preferredSize.height) + } + + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + var text = value + if (text is String) { + if (text.isBlank()) { + text = "<None>" + } + return super.getListCellRendererComponent( + list, + "$text", + index, + isSelected, + cellHasFocus + ) + } + return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) + } + } + fontComboBox.maximumSize = fontComboBox.preferredSize + + fontComboBox.addPopupMenuListener(object : PopupMenuListener { + override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { + if (fontsLoaded) return + val selectedItem = fontComboBox.selectedItem + val families = getItems() + for (family in FontUtils.getAvailableFontFamilyNames()) { + if (families.contains(family).not()) fontComboBox.addItem(family) + } + fontComboBox.selectedItem = selectedItem + fontsLoaded = true + } + + override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {} + override fun popupMenuCanceled(e: PopupMenuEvent) {} + }) + } + + + fun getItems(): Set { + val families = mutableSetOf() + for (i in 0 until itemCount) families.add(getItemAt(i)) + return families + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SettingsOptionsPane.kt b/src/main/kotlin/app/termora/SettingsOptionsPane.kt index 97f2436..58630e5 100644 --- a/src/main/kotlin/app/termora/SettingsOptionsPane.kt +++ b/src/main/kotlin/app/termora/SettingsOptionsPane.kt @@ -15,7 +15,6 @@ import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatToolBar -import com.formdev.flatlaf.util.FontUtils import com.formdev.flatlaf.util.SystemInfo import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout @@ -29,7 +28,6 @@ import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.SystemUtils import java.awt.BorderLayout import java.awt.Component -import java.awt.Dimension import java.awt.Toolkit import java.awt.event.ActionEvent import java.awt.event.ItemEvent @@ -399,7 +397,8 @@ class SettingsOptionsPane : OptionsPane() { private val debugComboBox = YesOrNoComboBox() private val beepComboBox = YesOrNoComboBox() private val cursorBlinkComboBox = YesOrNoComboBox() - private val fontComboBox = FlatComboBox() + private val fontComboBox = FontComboBox() + private val fallbackFontComboBox = FontComboBox() private val shellComboBox = FlatComboBox() private val maxRowsTextField = IntSpinner(0, 0) private val fontSizeTextField = IntSpinner(0, 9, 99) @@ -418,6 +417,13 @@ class SettingsOptionsPane : OptionsPane() { } } + fallbackFontComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + terminalSetting.fallbackFont = fallbackFontComboBox.selectedItem as String + fireFontChanged() + } + } + autoCloseTabComboBox.addItemListener { e -> if (e.stateChange == ItemEvent.SELECTED) { terminalSetting.autoCloseTabWhenDisconnected = autoCloseTabComboBox.selectedItem as Boolean @@ -526,33 +532,6 @@ class SettingsOptionsPane : OptionsPane() { } } - fontComboBox.renderer = object : DefaultListCellRenderer() { - init { - preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2) - maximumSize = Dimension(preferredSize.width, preferredSize.height) - } - - override fun getListCellRendererComponent( - list: JList<*>?, - value: Any?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean - ): Component { - if (value is String) { - return super.getListCellRendererComponent( - list, - "$value", - index, - isSelected, - cellHasFocus - ) - } - return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) - } - } - fontComboBox.maximumSize = fontComboBox.preferredSize - cursorStyleComboBox.addItem(CursorStyle.Block) cursorStyleComboBox.addItem(CursorStyle.Bar) cursorStyleComboBox.addItem(CursorStyle.Underline) @@ -566,29 +545,18 @@ class SettingsOptionsPane : OptionsPane() { shellComboBox.selectedItem = terminalSetting.localShell fontComboBox.addItem(terminalSetting.font) - var fontsLoaded = false + val items = fontComboBox.getItems() + for (family in listOf("JetBrains Mono", "Source Code Pro", "Monospaced")) { + if (items.contains(family).not()) fontComboBox.addItem(family) + } - fontComboBox.addPopupMenuListener(object : PopupMenuListener { - override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { - if (!fontsLoaded) { - val selectedItem = fontComboBox.selectedItem - fontComboBox.removeAllItems(); - fontComboBox.addItem("JetBrains Mono") - fontComboBox.addItem("Source Code Pro") - fontComboBox.addItem("Monospaced") - FontUtils.getAvailableFontFamilyNames().forEach { - fontComboBox.addItem(it) - } - fontComboBox.selectedItem = selectedItem - fontsLoaded = true - } - } - - override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {} - override fun popupMenuCanceled(e: PopupMenuEvent) {} - }) + if (terminalSetting.fallbackFont.isNotBlank()) { + fallbackFontComboBox.addItem(StringUtils.EMPTY) + } + fallbackFontComboBox.addItem(terminalSetting.fallbackFont) fontComboBox.selectedItem = terminalSetting.font + fallbackFontComboBox.selectedItem = terminalSetting.fallbackFont debugComboBox.selectedItem = terminalSetting.debug beepComboBox.selectedItem = terminalSetting.beep hyperlinkComboBox.selectedItem = terminalSetting.hyperlink @@ -627,7 +595,7 @@ class SettingsOptionsPane : OptionsPane() { private fun getCenterComponent(): JComponent { val layout = FormLayout( "left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, left:pref, $FORM_MARGIN, pref, default:grow", - "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref" + "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref" ) val beepBtn = JButton(Icons.run) @@ -643,6 +611,8 @@ class SettingsOptionsPane : OptionsPane() { .add(fontComboBox).xy(3, rows) .add("${I18n.getString("termora.settings.terminal.size")}:").xy(5, rows) .add(fontSizeTextField).xy(7, rows).apply { rows += step } + .add("${I18n.getString("termora.settings.terminal.fallback-font")}:").xy(1, rows) + .add(fallbackFontComboBox).xy(3, rows).apply { rows += step } .add("${I18n.getString("termora.settings.terminal.max-rows")}:").xy(1, rows) .add(maxRowsTextField).xy(3, rows).apply { rows += step } .add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows) diff --git a/src/main/kotlin/app/termora/database/DatabaseManager.kt b/src/main/kotlin/app/termora/database/DatabaseManager.kt index 665cfc1..52f0af1 100644 --- a/src/main/kotlin/app/termora/database/DatabaseManager.kt +++ b/src/main/kotlin/app/termora/database/DatabaseManager.kt @@ -619,6 +619,11 @@ class DatabaseManager private constructor() : Disposable { */ var font by StringPropertyDelegate("JetBrains Mono") + /** + * 回退字体 + */ + var fallbackFont by StringPropertyDelegate(StringUtils.EMPTY) + /** * 默认终端 */ diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt index 7b6d716..f01b0c7 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalDisplay.kt @@ -5,6 +5,7 @@ import app.termora.assertEventDispatchThread import app.termora.database.DatabaseManager import app.termora.swingCoroutineScope import app.termora.terminal.* +import com.formdev.flatlaf.util.SystemInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -23,9 +24,15 @@ class TerminalDisplay( private val terminalBlink: TerminalBlink ) : JComponent() { + enum class RendererFont { + Base, + Monospaced, + Fallback, + } + companion object { - private val lru = object : LinkedHashMap() { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + private val lru = object : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { return size > 2048 } } @@ -40,6 +47,7 @@ class TerminalDisplay( private var boldFont = font.deriveFont(Font.BOLD) private var italicFont = font.deriveFont(Font.ITALIC) private var boldItalicFont = font.deriveFont(Font.ITALIC or Font.BOLD) + private var fallbackFont = getFallbackTerminalFont() /** * 正在输入的内容 @@ -179,15 +187,22 @@ class TerminalDisplay( } private fun checkFont() { - // 如果字体已经改变,那么这里刷新字体 - if (font.family != DatabaseManager.getInstance().terminal.font - || font.size != DatabaseManager.getInstance().terminal.fontSize + val terminal = DatabaseManager.getInstance().terminal + + if ((terminal.fallbackFont.isNotBlank() && fallbackFont == null) || + (terminal.fallbackFont.isBlank() && fallbackFont != null) || + (terminal.fallbackFont != fallbackFont?.family) || + (font.size != terminal.fontSize) ) { + fallbackFont = getFallbackTerminalFont() + } + + if (font.family != terminal.font || font.size != terminal.fontSize) { font = getTerminalFont() - monospacedFont = Font(Font.MONOSPACED, font.style, font.size) boldFont = font.deriveFont(Font.BOLD) italicFont = font.deriveFont(Font.ITALIC) boldItalicFont = font.deriveFont(Font.ITALIC or Font.BOLD) + monospacedFont = Font(Font.MONOSPACED, font.style, font.size) } } @@ -395,7 +410,7 @@ class TerminalDisplay( fun getDisplayFont(text: String, style: TextStyle): Font { assertEventDispatchThread() - var font = if (style.bold && style.italic) { + val displayFont = if (style.bold && style.italic) { boldItalicFont } else if (style.italic) { italicFont @@ -405,17 +420,38 @@ class TerminalDisplay( font } + var font = displayFont + val key = "${font.fontName}:${font.style}:${font.size}:${text}" if (lru.containsKey(key)) { - if (!lru.getValue(key)) { - font = monospacedFont + val c = lru.getValue(key) + font = when (c) { + RendererFont.Base -> font + RendererFont.Fallback -> fallbackFont ?: monospacedFont + else -> monospacedFont } } else { - if ((font.canDisplayUpTo(text) != -1).also { lru[key] = !it }) { - font = monospacedFont + // >=0 表示不支持 + if (FontCanDisplay.canDisplayUpTo(font, text) != -1) { + val fallbackTerminalFont = fallbackFont ?: monospacedFont + font = if (fallbackTerminalFont.fontName == monospacedFont.fontName) { + monospacedFont + } else if (FontCanDisplay.canDisplayUpTo(fallbackTerminalFont, text) != -1) { + monospacedFont + } else { + fallbackTerminalFont + } } } + // macOS 比较特殊,因为它可以自动选择 PingFang,而 PingFang 在 macOS 效果最好(前提是回退字体可用的情况下) + if (SystemInfo.isMacOS) { + if (font == monospacedFont) { + font = displayFont + } + } + + return font } @@ -438,11 +474,17 @@ class TerminalDisplay( private fun getTerminalFont(): Font { - return Font( - DatabaseManager.getInstance().terminal.font, - Font.PLAIN, - DatabaseManager.getInstance().terminal.fontSize - ) + val terminal = DatabaseManager.getInstance().terminal + return Font(terminal.font, Font.PLAIN, terminal.fontSize) + } + + private fun getFallbackTerminalFont(): Font? { + val terminal = DatabaseManager.getInstance().terminal + return if (terminal.fallbackFont.isBlank()) { + null + } else { + Font(terminal.fallbackFont, Font.PLAIN, terminal.fontSize) + } } fun toast(text: String, duration: Duration) { @@ -509,4 +551,39 @@ class TerminalDisplay( } + private object FontCanDisplay { + fun canDisplayUpTo(font: Font, str: String): Int { + + if (SystemInfo.isWindows || SystemInfo.isLinux) { + return font.canDisplayUpTo(str) + } + + val getFontMethod = Font::class.java.getDeclaredMethod("getFont2D") + getFontMethod.isAccessible = true + val font2d = getFontMethod.invoke(font) + val getMapperMethod = font2d.javaClass.getDeclaredMethod("getMapper") + getMapperMethod.isAccessible = true + val mapper = getMapperMethod.invoke(font2d) + val charToGlyphMethod = mapper.javaClass.getDeclaredMethod("charToGlyph", Char::class.java) + + val len = str.length + var i = 0 + while (i < len) { + val c = str[i] + val glyph = charToGlyphMethod.invoke(mapper, c) as Int + if (glyph >= 0) { + i++ + continue + } + if (!Character.isHighSurrogate(c) + || (charToGlyphMethod.invoke(mapper, str.codePointAt(i)) as Int) < 0 + ) { + return i + } + i += 2 + } + return -1 + } + } + } \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 0c5ed44..42bed4e 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -63,6 +63,7 @@ termora.settings.appearance.confirm-tab-close=Confirm tab close termora.settings.terminal=Terminal termora.settings.terminal.font=Font +termora.settings.terminal.fallback-font=Fallback Font termora.settings.terminal.size=Size termora.settings.terminal.max-rows=Max rows termora.settings.terminal.debug=Debug mode diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index e9e262a..c37b690 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -76,6 +76,7 @@ termora.find-everywhere.quick-command.local-terminal=本地终端 termora.settings.terminal=终端 termora.settings.terminal.font=字体 +termora.settings.terminal.fallback-font=回退字体 termora.settings.terminal.size=大小 termora.settings.terminal.max-rows=最大行数 termora.settings.terminal.debug=调试模式 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 5119f9e..4201612 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -86,6 +86,7 @@ termora.find-everywhere.quick-command.local-terminal=本地端 termora.settings.terminal=終端 termora.settings.terminal.font=字體 +termora.settings.terminal.fallback-font=回退字體 termora.settings.terminal.size=大小 termora.settings.terminal.max-rows=最大行數 termora.settings.terminal.debug=偵錯模式