alt-tab-macos 设置侧边栏邮箱地址智能换行:EmailLineWrap 断行算法与实现解析

发布时间:2026/9/21 2:42:15
alt-tab-macos 设置侧边栏邮箱地址智能换行:EmailLineWrap 断行算法与实现解析
alt-tab-macos 设置侧边栏邮箱地址智能换行EmailLineWrap 断行算法与实现解析【免费下载链接】alt-tab-macosWindows alt-tab on macOS项目地址: https://gitcode.com/gh_mirrors/al/alt-tab-macos导读alt-tab-macos 的设置窗口SettingsWindow左侧边栏底部有一个升级胶囊按钮UpgradeButton激活 Pro 后会在按钮上显示Pro activated及用户的邮箱地址。由于邮箱地址通常就是用户姓名拼音它需要保留大号 13pt 半粗字体、通过按钮整体长高来容纳最多 3 行文字超出后才截断为省略号并把完整地址放进 tooltip。这篇技术指南以仓库内的 EmailLineWrapSpecs.md 规范文档为骨架结合 EmailLineWrap.swift 的完整实现与 EmailLineWrapTests.swift 的 11 个测试用例讲透这套测量-断行-截断-缩字的算法及其在真实界面中的集成方式。读完你可以掌握为什么 AppKit 原生控件做不到换行 截断兼得、如何在无空格文本上按分隔符智能断行、以及如何在半磅精度内通过收缩字号拯救整行布局。背景升级胶囊按钮上的邮箱地址从哪来设置窗口的侧边栏底部有一个渐变胶囊按钮UpgradeButton定义在 SettingsWindow.swift它承担两个职责未激活 Pro 时显示副标题试用剩余天数等与主标题 Get Pro已激活 Pro 时显示 Pro activated 或 Pro Lifetime activated 作为副标题主标题改为激活时返回的用户邮箱。邮箱来自许可证系统的激活响应LicenseAPI.swift中的ActivateResult携带customerEmail字段LicenseManager.activate成功后把它写入UserDefaults键名customerEmail见 LicenseManager.swift对外通过LicenseManager.shared.customerEmail读取见 LicenseManager.swift。关键约束在于邮箱地址经常就是用户的名字例如christopher.vandenbergheuniversiteit-amsterdam.nl因此界面设计选择让地址保留 13pt 半粗的大号字体、让按钮长高到最多 3 行来容纳它只有超过 3 行预算后才把尾部替换为省略号…并将完整地址放入 tooltip 供悬停查看。这个产品决策直接催生了EmailLineWrap这套专用换行算法。为什么 AppKit 原生能力不够用规范文档EmailLineWrapSpecs.md明确指出 AppKit 无法独自完成这件事NSButtonCell的文本渲染要么换行、要么截断二者不可兼得更致命的是它的换行按单词空格切分而邮箱地址没有空格导致折行会从单词中间硬切例如把christopher.vandenbergheuniversiteit-amsterdam.nl断成christopher.vandenbe/rgheuniversiteit-am可读性很差。因此设计上采取自研测量 自研断行的方案EmailLineWrap自己用NSString.size(withAttributes:)逐字符测量文本宽度并决定断点UpgradeButton再把断好的多行文本用\n拼接后交给按钮渲染并依据行数动态更新自身的高度约束。EmailLineWrap 的公开接口实现是纯函数式的enum不持有任何状态EmailLineWrap.swift对外暴露两个静态方法wrap(_ email:font:maxWidth:maxLines:) - Result核心入口返回Result结构体struct Result { let lines: [String] // 断行结果 let isTruncated: Bool // 是否因超过 maxLines 而截断 }参数含义与守卫条件如下参数含义说明email要布局的完整邮箱地址空字符串时直接返回空行、isTruncated falsefont测量与渲染使用的字体决定每个字符的宽度maxWidth每行可用的最大宽度pt 0时提前返回空结果maxLines允许的最大行数 0时提前返回空结果fittedFont(_ email:baseFont:maxWidth:minSize:maxLines:) - NSFont辅助方法在baseFont基础上按 0.5pt 步进收缩字号下限minSize仅当收缩能减少整行数时才返回缩小后的字体否则保留基础字体。核心算法贪心断行 分隔符回退wrap的主体是一个贪心循环EmailLineWrap.swift只要剩余文本非空且行数未达maxLines就测量剩余文本整体宽度若整体能放下直接作为最后一行结束否则调用breakIndex找到断点切下一行剩余部分进入下一轮。真正的难点在breakIndex——如何选一个既放得下、又不把单词从中间切碎的断点。最长可容纳前缀longestFittingPrefix先找出能塞进maxWidth的最长前缀EmailLineWrap.swift从第一个字符开始逐字符累加一旦加上下一个字符后宽度超过maxWidth就停止。这里有一个重要的进度保证返回的索引始终至少推进一个字符text.index(after: text.startIndex)起步所以即使maxWidth窄到连一个字符都放不下算法也会每行一个字符地继续推进而不是死循环。分隔符回退breakIndexbreakIndex在最长前缀的基础上做往回找分隔符的优化EmailLineWrap.swift。可用的分隔符集合定义在 EmailLineWrap.swiftprivate static let separators SetCharacter(.-_)回退逻辑分为两个阶段并配有两个不同的行饱满度阈值EmailLineWrap.swift断点类型阈值含义之后atFillRatio 0.45若local这行仍 ≥ 45% 宽就在后断开其他分隔符.-_之后separatorFillRatio 0.55若断点行仍 ≥ 55% 宽就在分隔符后断开设计动机把行拉到分隔符后面会损失掉分隔符之后那一小段宽度。如果拉回来之后行变得很秃填充率低于阈值观感反而不如干脆从单词中间硬切——所以用阈值来权衡可读性与空间利用率。两阶段回退的具体顺序优先先检查是否出现在最长前缀之内且local这行宽度仍 ≥ 45%maxWidth满足则直接在之后断开。的优先级高于后面的分隔符这保证了域名总是独占一行——例如jean-francois.dubois/entreprise-solutions.fr这正是人们阅读地址的自然方式其他分隔符回退若不在可及范围则从最长前缀末尾向前逐个字符扫描遇到.-_中的分隔符时检查该分隔符之后这一行是否仍 ≥ 55% 宽满足则在该分隔符后断开扫描不到任何分隔符或分隔符位置太靠前导致行太秃就保留贪心切点。尾部截断ellipsized当行数预算用尽、剩余文本仍未排完时最后一行要缩短到本身 …能放进maxWidth为止EmailLineWrap.swift并把Result.isTruncated置为true由调用方决定把完整地址放进 tooltip。边界情况与设计决策规范文档与测试用例共同锁定了以下行为每一条都有对应实现无损换行未截断时把各lines用拼接必须能还原出原地址测试testUntruncatedLinesRebuildTheAddress。因此断行处不插入任何字符——连字符-不能补因为地址里可能本来就含-补了会和真实字符混淆超预算截断超过maxLines后最后一行以…结尾且展示内容必须是原地址的前缀isTruncated置真由按钮把完整地址放进toolTip单行必有进展maxWidth小到连一个字符都放不下时仍按每行一个字符推进并正常结束而不是死循环测试testMakesProgressWhenNotEvenOneCharacterFits用 2pt 宽度验证空邮箱空字符串输入返回零行、不截断测试testEmptyEmailProducesNoLines无分隔符可断本地部分全是连续字符、没有任何分隔符可回退时直接按贪心切点填满每一行测试testBreaksAnywhereWhenThereIsNoSeparatorToBreakOn。字号收缩策略fittedFont 的半磅步进中等长度的地址最容易出现差一点点就能放进一行的尴尬。例如johncool-software.com在 13pt 下会差几个 pt结果把孤零零的com甩到第二行。fittedFont的解决思路非常克制EmailLineWrap.swift先用基础字体跑一遍wrap记录基准行数从baseFont.pointSize - 0.5开始按0.5pt 步进向下尝试直到minSize侧边栏场景为 11.5pt每次用NSFont(descriptor:size:)构造缩小后的字体并重跑wrap只要行数比基准少一行就立即返回该字体若遍历到minSize仍不能减少行数例如本来就一行放得下或地址在任意字号下都超过 3 行预算则返回基础字体——收缩救不了就不缩。这个只有省下一整行才缩字的原则避免了一行能放下的地址被无谓缩小也避免了超长地址缩到 11.5pt 依然放不下时字体反而变小测试testShrinksTheFontOnlyWhenThatSavesALine与testKeepsTheBaseFontWhenShrinkingWouldNotSaveALine分别验证了这两个方向。与 UpgradeButton 的集成从断行到真实布局EmailLineWrap只是纯函数真正把结果变成界面的是UpgradeButton.setEmailTitleSettingsWindow.swiftlet width availableTitleWidth let font EmailLineWrap.fittedFont(email, baseFont: Self.mainFont, maxWidth: width, minSize: Self.emailMinFontSize, maxLines: Self.emailMaxLines) let wrapped EmailLineWrap.wrap(email, font: font, maxWidth: width, maxLines: Self.emailMaxLines) toolTip wrapped.isTruncated ? email : nil applyTitle(secondary: title, main: Self.attributed(wrapped.lines.joined(separator: \n), font), height: Self.twoLineHeight CGFloat(wrapped.lines.count - 1) * Self.lineHeight(font))集成要点可用宽度availableTitleWidthSettingsWindow.swift在按钮首次布局前用侧边栏几何兜底——侧边栏宽sidebarWidth 175pt、水平内边距sidebarHorizontalPadding 10pt再减去按钮标题的左右内边距titleHorizontalInset 6pt × 2得到143pt的真实可用宽度行数 → 高度约束主标题文本用\n拼接成多行富文本按钮高度约束按副标题行 主标题行数动态计算twoLineHeight (35) (行数 - 1) × 行高行高用ceil(ascender - descender leading)计算见 SettingsWindow.swifttooltip 兜底isTruncated true时把完整地址放进toolTip悬停即可看到被…隐藏的尾部按宽度缓存重排UpgradeButton.layout()只在bounds.width变化侧边栏尺寸变化时才重新执行断行wrappedWidth缓存见 SettingsWindow.swift避免无谓的重复测量。此外副标题与主标题统一通过applyTitle组装为NSAttributedString居中对齐并设置.byTruncatingTail作为最终保险SettingsWindow.swift。测试场景真实宽度下的 11 个用例EmailLineWrapTests.swift 与规范文档 1:1 对应且用的是真实几何参数13pt semibold 系统字体、maxWidth 143pt、maxLines 3、fittedFont的minSize 11.5见测试文件头部注释与 EmailLineWrapTests.swift。测试用例验证点testShortEmailStaysOnOneLine放得下的地址原样返回单行、不截断testBreaksAfterTheAtSignjean-francois.duboisentreprise-solutions.fr断为jean-francois.dubois/entreprise-solutions.fr域名独占一行testBreaksAfterASeparatorWhenTheAtSignIsOutOfReachchristopher.vandenbergheuniversiteit-amsterdam.nl的第 2、3 行在-后断开而非单词中间testKeepsEveryLineWithinTheAvailableWidth遍历多个地址断言每行宽度都不超过 143pttestUntruncatedLinesRebuildTheAddresslines.joined()可还原原地址断行无损testTruncatesTheTailPastTheLineBudget78 字符长地址输出恰好 3 行、末行以…结尾、且展示内容是原地址前缀testBreaksAnywhereWhenThereIsNoSeparatorToBreakOn无分隔符的长单词直接填满各行testMakesProgressWhenNotEvenOneCharacterFits2pt 宽度下依然能终止每行一个字符testEmptyEmailProducesNoLines空输入 → 零行、不截断testShrinksTheFontOnlyWhenThatSavesALinejohncool-software.com缩字后从 2 行变 1 行testKeepsTheBaseFontWhenShrinkingWouldNotSaveALine单行地址与任意字号都超预算的地址都保持 13pt其中testTruncatesTheTailPastTheLineBudget使用的 78 字符示例地址maximilian.von-hohenzollernforschungsinstitut-bioinformatik.uni-heidelberg.de与testShrinksTheFontOnlyWhenThatSavesALine使用的johncool-software.com恰好对应规范文档中末尾孤独的com这一典型场景——这些用例把每个设计决策都固化为可回归的行为契约。小结EmailLineWrap是 alt-tab-macos 中一个小而精的布局组件它绕开 AppKit 原生控件换行与截断不可兼得的限制用逐字符宽度测量、分隔符回退、填充率阈值、无损拼接、…截断与半磅步进缩字把任意长度的邮箱地址塞进 143pt 宽、最多 3 行的胶囊按钮这件事做到了可读、可预测、可测试。配套的规范文档EmailLineWrapSpecs.md、实现EmailLineWrap.swift、集成SettingsWindow.swift与测试EmailLineWrapTests.swift四者互为印证是阅读macOS 应用自定义文本布局类需求的绝佳参考样例——其测量优先、阈值权衡、进度保证的思路同样适用于 tooltip、标签页、状态栏等任何需要在有限宽度内展示无空格长文本的场景。【免费下载链接】alt-tab-macosWindows alt-tab on macOS项目地址: https://gitcode.com/gh_mirrors/al/alt-tab-macos创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考