feat: support fallback font

This commit is contained in:
hstyi
2025-07-04 12:19:56 +08:00
committed by GitHub
parent 19fbeab817
commit accf590c17
8 changed files with 194 additions and 67 deletions

View File

@@ -437,6 +437,7 @@ tasks.register<Exec>("jpackage") {
// NSWindow // NSWindow
options.add("-Dapple.awt.application.appearance=system") options.add("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/java.awt=ALL-UNNAMED") 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=ALL-UNNAMED")
options.add("--add-opens java.desktop/sun.lwawt.macosx=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") options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")

View File

@@ -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<String>() {
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 = "&lt;None&gt;"
}
return super.getListCellRendererComponent(
list,
"<html><font face='$text'>$text</font></html>",
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<String> {
val families = mutableSetOf<String>()
for (i in 0 until itemCount) families.add(getItemAt(i))
return families
}
}

View File

@@ -15,7 +15,6 @@ import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.util.FontUtils
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
@@ -29,7 +28,6 @@ import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Dimension
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
@@ -399,7 +397,8 @@ class SettingsOptionsPane : OptionsPane() {
private val debugComboBox = YesOrNoComboBox() private val debugComboBox = YesOrNoComboBox()
private val beepComboBox = YesOrNoComboBox() private val beepComboBox = YesOrNoComboBox()
private val cursorBlinkComboBox = YesOrNoComboBox() private val cursorBlinkComboBox = YesOrNoComboBox()
private val fontComboBox = FlatComboBox<String>() private val fontComboBox = FontComboBox()
private val fallbackFontComboBox = FontComboBox()
private val shellComboBox = FlatComboBox<String>() private val shellComboBox = FlatComboBox<String>()
private val maxRowsTextField = IntSpinner(0, 0) private val maxRowsTextField = IntSpinner(0, 0)
private val fontSizeTextField = IntSpinner(0, 9, 99) 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 -> autoCloseTabComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.autoCloseTabWhenDisconnected = autoCloseTabComboBox.selectedItem as Boolean 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,
"<html><font face='$value'>$value</font></html>",
index,
isSelected,
cellHasFocus
)
}
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
}
}
fontComboBox.maximumSize = fontComboBox.preferredSize
cursorStyleComboBox.addItem(CursorStyle.Block) cursorStyleComboBox.addItem(CursorStyle.Block)
cursorStyleComboBox.addItem(CursorStyle.Bar) cursorStyleComboBox.addItem(CursorStyle.Bar)
cursorStyleComboBox.addItem(CursorStyle.Underline) cursorStyleComboBox.addItem(CursorStyle.Underline)
@@ -566,29 +545,18 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell shellComboBox.selectedItem = terminalSetting.localShell
fontComboBox.addItem(terminalSetting.font) fontComboBox.addItem(terminalSetting.font)
var fontsLoaded = false val items = fontComboBox.getItems()
for (family in listOf("JetBrains Mono", "Source Code Pro", "Monospaced")) {
fontComboBox.addPopupMenuListener(object : PopupMenuListener { if (items.contains(family).not()) fontComboBox.addItem(family)
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) {} if (terminalSetting.fallbackFont.isNotBlank()) {
override fun popupMenuCanceled(e: PopupMenuEvent) {} fallbackFontComboBox.addItem(StringUtils.EMPTY)
}) }
fallbackFontComboBox.addItem(terminalSetting.fallbackFont)
fontComboBox.selectedItem = terminalSetting.font fontComboBox.selectedItem = terminalSetting.font
fallbackFontComboBox.selectedItem = terminalSetting.fallbackFont
debugComboBox.selectedItem = terminalSetting.debug debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep beepComboBox.selectedItem = terminalSetting.beep
hyperlinkComboBox.selectedItem = terminalSetting.hyperlink hyperlinkComboBox.selectedItem = terminalSetting.hyperlink
@@ -627,7 +595,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent { private fun getCenterComponent(): JComponent {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, left:pref, $FORM_MARGIN, pref, default:grow", "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) val beepBtn = JButton(Icons.run)
@@ -643,6 +611,8 @@ class SettingsOptionsPane : OptionsPane() {
.add(fontComboBox).xy(3, rows) .add(fontComboBox).xy(3, rows)
.add("${I18n.getString("termora.settings.terminal.size")}:").xy(5, rows) .add("${I18n.getString("termora.settings.terminal.size")}:").xy(5, rows)
.add(fontSizeTextField).xy(7, rows).apply { rows += step } .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("${I18n.getString("termora.settings.terminal.max-rows")}:").xy(1, rows)
.add(maxRowsTextField).xy(3, rows).apply { rows += step } .add(maxRowsTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows)

View File

@@ -619,6 +619,11 @@ class DatabaseManager private constructor() : Disposable {
*/ */
var font by StringPropertyDelegate("JetBrains Mono") var font by StringPropertyDelegate("JetBrains Mono")
/**
* 回退字体
*/
var fallbackFont by StringPropertyDelegate(StringUtils.EMPTY)
/** /**
* 默认终端 * 默认终端
*/ */

View File

@@ -5,6 +5,7 @@ import app.termora.assertEventDispatchThread
import app.termora.database.DatabaseManager import app.termora.database.DatabaseManager
import app.termora.swingCoroutineScope import app.termora.swingCoroutineScope
import app.termora.terminal.* import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -23,9 +24,15 @@ class TerminalDisplay(
private val terminalBlink: TerminalBlink private val terminalBlink: TerminalBlink
) : JComponent() { ) : JComponent() {
enum class RendererFont {
Base,
Monospaced,
Fallback,
}
companion object { companion object {
private val lru = object : LinkedHashMap<String, Boolean>() { private val lru = object : LinkedHashMap<String, RendererFont>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>?): Boolean { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, RendererFont>?): Boolean {
return size > 2048 return size > 2048
} }
} }
@@ -40,6 +47,7 @@ class TerminalDisplay(
private var boldFont = font.deriveFont(Font.BOLD) private var boldFont = font.deriveFont(Font.BOLD)
private var italicFont = font.deriveFont(Font.ITALIC) private var italicFont = font.deriveFont(Font.ITALIC)
private var boldItalicFont = font.deriveFont(Font.ITALIC or Font.BOLD) private var boldItalicFont = font.deriveFont(Font.ITALIC or Font.BOLD)
private var fallbackFont = getFallbackTerminalFont()
/** /**
* 正在输入的内容 * 正在输入的内容
@@ -179,15 +187,22 @@ class TerminalDisplay(
} }
private fun checkFont() { private fun checkFont() {
// 如果字体已经改变,那么这里刷新字体 val terminal = DatabaseManager.getInstance().terminal
if (font.family != DatabaseManager.getInstance().terminal.font
|| font.size != DatabaseManager.getInstance().terminal.fontSize 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() font = getTerminalFont()
monospacedFont = Font(Font.MONOSPACED, font.style, font.size)
boldFont = font.deriveFont(Font.BOLD) boldFont = font.deriveFont(Font.BOLD)
italicFont = font.deriveFont(Font.ITALIC) italicFont = font.deriveFont(Font.ITALIC)
boldItalicFont = font.deriveFont(Font.ITALIC or Font.BOLD) 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 { fun getDisplayFont(text: String, style: TextStyle): Font {
assertEventDispatchThread() assertEventDispatchThread()
var font = if (style.bold && style.italic) { val displayFont = if (style.bold && style.italic) {
boldItalicFont boldItalicFont
} else if (style.italic) { } else if (style.italic) {
italicFont italicFont
@@ -405,16 +420,37 @@ class TerminalDisplay(
font font
} }
var font = displayFont
val key = "${font.fontName}:${font.style}:${font.size}:${text}" val key = "${font.fontName}:${font.style}:${font.size}:${text}"
if (lru.containsKey(key)) { if (lru.containsKey(key)) {
if (!lru.getValue(key)) { val c = lru.getValue(key)
font = monospacedFont font = when (c) {
RendererFont.Base -> font
RendererFont.Fallback -> fallbackFont ?: monospacedFont
else -> monospacedFont
} }
} else { } else {
if ((font.canDisplayUpTo(text) != -1).also { lru[key] = !it }) { // >=0 表示不支持
font = monospacedFont 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 return font
@@ -438,11 +474,17 @@ class TerminalDisplay(
private fun getTerminalFont(): Font { private fun getTerminalFont(): Font {
return Font( val terminal = DatabaseManager.getInstance().terminal
DatabaseManager.getInstance().terminal.font, return Font(terminal.font, Font.PLAIN, terminal.fontSize)
Font.PLAIN, }
DatabaseManager.getInstance().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) { 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
}
}
} }

View File

@@ -63,6 +63,7 @@ termora.settings.appearance.confirm-tab-close=Confirm tab close
termora.settings.terminal=Terminal termora.settings.terminal=Terminal
termora.settings.terminal.font=Font termora.settings.terminal.font=Font
termora.settings.terminal.fallback-font=Fallback Font
termora.settings.terminal.size=Size termora.settings.terminal.size=Size
termora.settings.terminal.max-rows=Max rows termora.settings.terminal.max-rows=Max rows
termora.settings.terminal.debug=Debug mode termora.settings.terminal.debug=Debug mode

View File

@@ -76,6 +76,7 @@ termora.find-everywhere.quick-command.local-terminal=本地终端
termora.settings.terminal=终端 termora.settings.terminal=终端
termora.settings.terminal.font=字体 termora.settings.terminal.font=字体
termora.settings.terminal.fallback-font=回退字体
termora.settings.terminal.size=大小 termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行数 termora.settings.terminal.max-rows=最大行数
termora.settings.terminal.debug=调试模式 termora.settings.terminal.debug=调试模式

View File

@@ -86,6 +86,7 @@ termora.find-everywhere.quick-command.local-terminal=本地端
termora.settings.terminal=終端 termora.settings.terminal=終端
termora.settings.terminal.font=字體 termora.settings.terminal.font=字體
termora.settings.terminal.fallback-font=回退字體
termora.settings.terminal.size=大小 termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行數 termora.settings.terminal.max-rows=最大行數
termora.settings.terminal.debug=偵錯模式 termora.settings.terminal.debug=偵錯模式