Files
terminalX/apps/TerminalX/iOS/Theme.swift
kid 5707d70dff feat: 侧边栏两态重构(统一视图身份)+ 第四轮触控化走查
侧边栏(真机验证):
- 砍掉悬浮态,只留收起⇄固定展开;TerminalSidebar 单一视图身份,
  内容恒按 288pt 排版、收起=左侧裁切(clipped 宽度动画),
  图标列两态同尺寸同位置,明细淡入淡出,终端随宽度盖上/让开
- 开合入口收敛:Logo 位(收起态=»展开键/展开态=Logo)+ 头部«;
  删底部开合键与 Pin;LeadingHitShape 修 clipped 不裁触摸
- 修 GridBox 从不通知 UI:onGridChange→objectWillChange,
  标题胶囊格子数随开合实时刷新

触控化走查(上一轮,真机部署验证):
- 窗口按钮 13pt 色点→22pt 圆角方块常显字形;动作条改可点按钮
- TXTech 拼写/配色规范(SSH/mosh/tmux 官方写法+品牌色映射)
- 首页会话卡固定 3 列 4:3;去掉未实现的 ⌘K 提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-26 00:55:53 +08:00

435 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import UIKit
import GhosttyTerminal
import GhosttyTheme
// MARK: - docs/design/UI-DESIGN-HANDOFF.md
//
//
// TXAccent Nocturne blurple****线
// TXChrome app chrome Catppuccin flavor 4
// TXFlavor Catppuccin flavor ****libghostty +
// TXPalette palette
/// Nocturne blurple
enum TXAccent {
/// `--color-accent`线
static let base = Color(hex: 0x9184D9)
/// accent-600
static let border = Color(hex: 0x796CBF)
/// accent-900
static let selectedBg = Color(hex: 0x2B2741)
/// accent-800 inset stroke 1pt
static let selectedStroke = Color(hex: 0x423A6A)
/// accent-400
static let text = Color(hex: 0xB5ABFC)
/// accent-300 hover/
static let textBright = Color(hex: 0xD2CEFD)
/// accent-500
static let mid = Color(hex: 0x968AE0)
/// accent-700 inset
static let dim = Color(hex: 0x5D5294)
}
/// macOS 沿/
enum TXWindowDot {
static let close = Color(hex: 0xFF5F57)
static let minimize = Color(hex: 0xFEBC2E)
}
/// app chrome **** TXAccent
struct TXChrome: Equatable {
/// / pane
let bgDeep: UInt32
/// /
let bg: UInt32
/// / /
let surfaceDark: UInt32
///
let surface: UInt32
/// 线
let divider: UInt32
///
let border: UInt32
/// /
let borderStrong: UInt32
/// /
let borderDialog: UInt32
///
let text: UInt32
/// neutral-300
let text2: UInt32
/// neutral-400
let text3: UInt32
/// neutral-500
let text4: UInt32
/// / neutral-600
let textWeak: UInt32
/// neutral-700
let textFaint: UInt32
}
extension TXChrome {
/// Mocha = **** flavor HSL docs/design/render-screens.py
static let mocha = TXChrome(
bgDeep: 0x11121C, bg: 0x161826, surfaceDark: 0x1B1D2B, surface: 0x232532,
divider: 0x2C2F3D, border: 0x3F424D, borderStrong: 0x595D6C, borderDialog: 0x4A4D5E,
text: 0xE9E9ED, text2: 0xCFD3E5, text3: 0xB2B6CA, text4: 0x9397AB,
textWeak: 0x75798C, textFaint: 0x4A4D5E)
/// Macchiato flavor base Mocha /
static let macchiato = TXChrome(
bgDeep: 0x171B28, bg: 0x1B2133, surfaceDark: 0x212737, surface: 0x292F3E,
divider: 0x323949, border: 0x464C58, borderStrong: 0x606877, borderDialog: 0x515869,
text: 0xEDEEF1, text2: 0xD3DAE9, text3: 0xB6BDCF, text4: 0x969EB0,
textWeak: 0x788091, textFaint: 0x515869)
static let frappe = TXChrome(
bgDeep: 0x222735, bg: 0x272E3F, surfaceDark: 0x2D3443, surface: 0x363C49,
divider: 0x3F4754, border: 0x535963, borderStrong: 0x6D7582, borderDialog: 0x5E6574,
text: 0xF4F4F5, text2: 0xDCE1EB, text3: 0xBEC5D1, text4: 0x9FA6B2,
textWeak: 0x818793, textFaint: 0x5E6574)
/// Latte flavor""
static let latte = TXChrome(
bgDeep: 0xE4E6ED, bg: 0xF3F4F8, surfaceDark: 0xFAFBFD, surface: 0xEBEDF3,
divider: 0xDCDEE7, border: 0xC7CAD6, borderStrong: 0xA9ADBD, borderDialog: 0xB8BCCA,
text: 0x2A2C3A, text2: 0x43465A, text3: 0x5A5E73, text4: 0x757994,
textWeak: 0x8E92A6, textFaint: 0xB0B4C4)
}
/// Catppuccin flavor +
struct TXFlavor: Equatable {
let id: String
let name: String
let isDark: Bool
/// GhosttyTheme
let ghosttyName: String
// Catppuccin
let green: UInt32 // 线
let red: UInt32 // error
let yellow: UInt32 // warninggit
let blue: UInt32 //
let teal: UInt32 // mosh
let peach: UInt32 //
let overlay: UInt32 // 线
/// Catppuccin base
/// libghostty pane
let terminalBg: UInt32
}
// MARK: - Palette
/// **app chrome** ****`ghosttyName` GhosttyTheme
/// Nocturne blurple = `static let`
struct TXPalette: Equatable, Identifiable {
let flavor: TXFlavor
let chrome: TXChrome
var id: String { flavor.id }
var name: String { flavor.name }
var isDark: Bool { flavor.isDark }
var ghosttyName: String { flavor.ghosttyName }
/// GhosttyTheme TerminalTheme退
var terminalTheme: TerminalTheme {
GhosttyThemeCatalog.theme(named: flavor.ghosttyName)?.toTerminalTheme() ?? .default
}
// MARK: chrome token
var bgDeep: Color { Color(hex: chrome.bgDeep) }
var bg: Color { Color(hex: chrome.bg) }
var surfaceDark: Color { Color(hex: chrome.surfaceDark) }
var surface: Color { Color(hex: chrome.surface) }
var divider: Color { Color(hex: chrome.divider) }
var border: Color { Color(hex: chrome.border) }
var borderStrong: Color { Color(hex: chrome.borderStrong) }
var borderDialog: Color { Color(hex: chrome.borderDialog) }
var text: Color { Color(hex: chrome.text) }
var text2: Color { Color(hex: chrome.text2) }
var text3: Color { Color(hex: chrome.text3) }
var text4: Color { Color(hex: chrome.text4) }
var textWeak: Color { Color(hex: chrome.textWeak) }
var textFaint: Color { Color(hex: chrome.textFaint) }
// MARK: flavor
/// 线 / /
var online: Color { Color(hex: flavor.green) }
/// error
var danger: Color { Color(hex: flavor.red) }
///
var reconnecting: Color { Color(hex: flavor.peach) }
var linkGood: Color { Color(hex: flavor.green) }
var linkPath: Color { Color(hex: flavor.blue) }
/// /
var terminalBackground: Color { Color(hex: flavor.terminalBg) }
// MARK:
// 稿 chrome/TXAccent
var surfaceElevated: Color { divider }
var accent: Color { TXAccent.base }
var accentSoft: Color { TXAccent.selectedBg }
var textPrimary: Color { text }
var textSecondary: Color { text3 }
var textTertiary: Color { textWeak }
var running: Color { online }
var mosh: Color { Color(hex: flavor.teal) }
var warning: Color { Color(hex: flavor.yellow) }
var offline: Color { Color(hex: flavor.overlay) }
}
extension TXPalette {
static let catppuccinMocha = TXPalette(
flavor: TXFlavor(
id: "catppuccin-mocha", name: "Mocha", isDark: true, ghosttyName: "Catppuccin Mocha",
green: 0xA6E3A1, red: 0xF38BA8, yellow: 0xF9E2AF, blue: 0x89B4FA,
teal: 0x94E2D5, peach: 0xFAB387, overlay: 0x6C7086, terminalBg: 0x1E1E2E),
chrome: .mocha)
static let catppuccinMacchiato = TXPalette(
flavor: TXFlavor(
id: "catppuccin-macchiato", name: "Macchiato", isDark: true, ghosttyName: "Catppuccin Macchiato",
green: 0xA6DA95, red: 0xED8796, yellow: 0xEED49F, blue: 0x8AADF4,
teal: 0x8BD5CA, peach: 0xF5A97F, overlay: 0x6E738D, terminalBg: 0x24273A),
chrome: .macchiato)
static let catppuccinFrappe = TXPalette(
flavor: TXFlavor(
id: "catppuccin-frappe", name: "Frappé", isDark: true, ghosttyName: "Catppuccin Frappe",
green: 0xA6D189, red: 0xE78284, yellow: 0xE5C890, blue: 0x8CAAEE,
teal: 0x81C8BE, peach: 0xEF9F76, overlay: 0x737994, terminalBg: 0x303446),
chrome: .frappe)
static let catppuccinLatte = TXPalette(
flavor: TXFlavor(
id: "catppuccin-latte", name: "Latte", isDark: false, ghosttyName: "Catppuccin Latte",
green: 0x40A02B, red: 0xD20F39, yellow: 0xDF8E1D, blue: 0x1E66F5,
teal: 0x179299, peach: 0xFE640B, overlay: 0x9CA0B0, terminalBg: 0xEFF1F5),
chrome: .latte)
///
static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte]
static let `default` = catppuccinMocha
static func byID(_ id: String?) -> TXPalette {
all.first { $0.id == id } ?? .default
}
}
/// app + UserDefaults `TerminalTheme.txDefault`
/// nonisolated app
let txThemeStorageKey = "txThemeID"
@MainActor
final class TXThemeManager: ObservableObject {
@Published var palette: TXPalette
init() {
palette = TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey))
}
/// + app ContentView palette.id
func apply(_ palette: TXPalette) {
self.palette = palette
UserDefaults.standard.set(palette.id, forKey: txThemeStorageKey)
}
}
extension TerminalTheme {
/// app model / tmux pane
static var txDefault: TerminalTheme {
TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey)).terminalTheme
}
}
// MARK: -
/// / / /
enum TX {
enum Radius {
/// /
static let badge: CGFloat = 6
///
static let control: CGFloat = 9
///
static let card: CGFloat = 12
///
static let dialog: CGFloat = 15
/// chrome/
static let capsule: CGFloat = 11
/// pane ** app chrome **
/// `TerminalScreen.paneShape`
static let pane: CGFloat = 6
//
static let chip: CGFloat = 9
static let field: CGFloat = 9
}
/// 2 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 26 / 34 / 40
enum Space {
static let hairline: CGFloat = 2
static let xs: CGFloat = 6
static let s: CGFloat = 10
static let m: CGFloat = 16
static let l: CGFloat = 20
static let xl: CGFloat = 26
static let xxl: CGFloat = 34
/// /
static let screenGutter: CGFloat = 40
}
/// pt `.compact`
enum Layout {
/// 48
static let railWidth: CGFloat = 56
static let railWidthCompact: CGFloat = 48
///
static let sidebarWidth: CGFloat = 288
/// pane
static let paneGap: CGFloat = 2
///
static let paneDragHit: CGFloat = 12
///
static let minTouch: CGFloat = 44
/// pane
static let capsuleInset: CGFloat = 86
/// **** / / 齿线
/// 线 y home indicator
/// chrome `TX.Layout.footBand`
static let footHeight: CGFloat = 34
/// = + ****
/// home indicator
static func footBand(_ safeBottom: CGFloat) -> CGFloat { footHeight + safeBottom }
/// ****
///
/// iPad 14/
/// 30
static func capsuleTop(_ safeTop: CGFloat) -> CGFloat {
safeTop > 30 ? safeTop - 12 : 14
}
}
/// 稿 200260ms `easeOut`
///
/// 线 URL
/// 退
enum Motion {
///
@MainActor static var standard: Animation {
.easeOut(duration: (reduceMotion ? 0.12 : 0.24) * slowFactor)
}
///
@MainActor static var quick: Animation {
.easeOut(duration: (reduceMotion ? 0.08 : 0.14) * slowFactor)
}
@MainActor static var reduceMotion: Bool { UIAccessibility.isReduceMotionEnabled }
/// `-txSlowMotion 1` ×12
///
/// `simctl io screenshot` 240ms
/// ""退 DEBUG
private static var slowFactor: Double {
#if DEBUG
UserDefaults.standard.bool(forKey: "txSlowMotion") ? 12 : 1
#else
1
#endif
}
///
@MainActor static func slide(_ edge: Edge) -> AnyTransition {
reduceMotion ? .opacity : .move(edge: edge)
}
/// + 96% 100%""
@MainActor static var pop: AnyTransition {
reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.96))
}
/// 97% ""
@MainActor static var pushBack: AnyTransition {
reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 1.02))
}
}
/// **稿** chrome
/// 稿 CSS 3080px / .55.7
/// 穿""
struct Shadow {
let opacity: Double
let radius: CGFloat
var x: CGFloat = 0
var y: CGFloat = 0
}
enum Elevation {
/// chrome /
static let floating = Shadow(opacity: 0.22, radius: 7, y: 2)
///
static let panel = Shadow(opacity: 0.28, radius: 16, x: 4)
/// /
static let dialog = Shadow(opacity: 0.34, radius: 16, y: 6)
///
static let card = Shadow(opacity: 0.16, radius: 4, y: 2)
static let cardFocused = Shadow(opacity: 0.24, radius: 8, y: 3)
}
///
enum Scrim {
static let color = Color(hex: 0x0A0B12)
/// ""
static let dialog: Double = 0.5
///
static let sidebar: Double = 0.42
}
enum Font {
/// JetBrains Monochrome SwiftUI 退 PingFang SC
static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
.custom(psName(weight), fixedSize: size)
}
///
static func monoScaled(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
.custom(psName(weight), size: size)
}
private static func psName(_ weight: SwiftUI.Font.Weight) -> String {
switch weight {
case .bold, .heavy, .black: return "JetBrainsMono-Bold"
case .semibold: return "JetBrainsMono-SemiBold"
case .medium: return "JetBrainsMono-Medium"
default: return "JetBrainsMono-Regular"
}
}
/// 600 10px, ls .09em, uppercase
static let sectionLabel = mono(10, .semibold)
/// 500 34px
static let screenTitle = mono(34, .medium)
/// 500 24px
static let pageTitle = mono(24, .medium)
/// 500 13px
static let listRow = mono(13, .medium)
/// / 400 11px
static let listMeta = mono(11)
/// / 500 12px
static let badge = mono(12, .medium)
}
}
extension Color {
/// 0xRRGGBB
init(hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
}