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>
This commit is contained in:
kid
2026-07-26 00:55:53 +08:00
parent 28e9cfc207
commit 5707d70dff
15 changed files with 1086 additions and 675 deletions

View File

@@ -14,21 +14,19 @@ enum AppRoute: Equatable {
}
}
///
///
enum SidebarState: Equatable {
/// 56pt
case collapsed
/// 288pt **** reflow PTY resize
case overlay
/// 288pt **/ resize **
/// 288pt resize
case pinned
var isExpanded: Bool { self != .collapsed }
var isExpanded: Bool { self == .pinned }
/// overlay
///
var layoutWidth: CGFloat {
switch self {
case .collapsed, .overlay: TX.Layout.railWidth
case .collapsed: TX.Layout.railWidth
case .pinned: TX.Layout.sidebarWidth
}
}
@@ -51,47 +49,34 @@ final class AppRouter: ObservableObject {
/// /
@Published var modal: AppModal?
/// 稿 200260ms easeOut +
/// 线
static let sidebarAnimation = Animation.easeOut(duration: 0.24)
private func animateSidebar(_ change: () -> Void) {
withAnimation(Self.sidebarAnimation, change)
/// `TX.Motion.standard`稿 200260ms easeOut
///
/// ** / / 线** `transition`
/// 退 ""
/// router URL
private func animated(_ change: () -> Void) {
withAnimation(TX.Motion.standard, change)
}
/// / /
func enter(_ sessionID: UUID) {
route = .terminal(sessionID)
animated { route = .terminal(sessionID) }
}
/// H /
/// H /
func goHome() {
modal = nil
route = .home
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
/// \ / Pin
func toggleSidebar() {
animateSidebar {
switch sidebar {
case .collapsed: sidebar = .overlay
case .overlay, .pinned: sidebar = .collapsed
}
animated {
modal = nil
route = .home
}
}
/// Pin / resize
func togglePin() {
animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned }
/// \ / Logo /
func toggleSidebar() {
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
}
/// /WDD URL
func present(_ modal: AppModal) { self.modal = modal }
func dismissModal() { modal = nil }
/// Pin
func dismissOverlaySidebar() {
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
func present(_ modal: AppModal) { animated { self.modal = modal } }
func dismissModal() { animated { modal = nil } }
}

View File

@@ -94,7 +94,8 @@ struct RootView: View {
if router.route == .home {
HomeView(manager: manager, router: router, hostStore: hostStore,
credStore: credStore, tailscaleStore: tailscaleStore)
.transition(.opacity)
// 97% ""
.transition(TX.Motion.pushBack)
}
}
.background(p.bgDeep.ignoresSafeArea())
@@ -142,6 +143,7 @@ struct SessionCanvas: View {
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
@@ -153,61 +155,55 @@ struct SessionCanvas: View {
// ""
// `scaleEffect` frame ** PTY resize**
// frame tmux
.scaleEffect(visible ? 1 : 0.97)
.animation(AppRouter.sidebarAnimation, value: visible)
.scaleEffect(visible || reduceMotion ? 1 : 0.97)
.animation(TX.Motion.standard, value: visible)
.allowsHitTesting(visible)
}
}
}
}
/// · 稿 `1b` + `3a`
/// · 稿 `1b` + `3a`/
///
/// 56pt Pin 288pt + 86pt
/// 34pt **** reflow resize
/// Pin resize
/// `TerminalSidebar`56 288+ 86pt
///
/// resize
///
/// **** chrome ****`ignoresSafeArea()`
/// / `TX.Layout.footBand`线 y
struct TerminalScreen: View {
@ObservedObject var session: TerminalSession
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
@State private var sidebarSearch = ""
/// tmux
@State private var showTmuxInstall = false
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 0) {
if router.sidebar == .pinned {
sidebarPanel
} else {
RailView(manager: manager, router: router, session: session,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost)
}
stage
}
.background(p.bgDeep.ignoresSafeArea())
// 稿 1b/3a /logo RootView
// SessionCanvas hide
.ignoresSafeArea(edges: .top)
// 50%
// + 200260ms easeOut AppRouter
.overlay(alignment: .leading) {
if router.sidebar == .overlay {
ZStack(alignment: .leading) {
Color(hex: 0x0A0B12).opacity(0.5)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture { router.dismissOverlaySidebar() }
.transition(.opacity)
sidebarPanel
.transition(.move(edge: .leading))
}
// safe area
// TXScreenMetrics
TXScreenMetrics { geo in
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
.ignoresSafeArea()
}
}
}
private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
HStack(spacing: 0) {
// **** TerminalSidebar
// 56 288
// resize
TerminalSidebar(manager: manager, router: router, session: session,
tailscaleStore: tailscaleStore,
railWidth: railWidth, safeBottom: safeBottom,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
search: $sidebarSearch)
stage(safeTop: safeTop, safeBottom: safeBottom)
}
.background(p.bgDeep)
// 3c3b alert/sheet
.overlay {
switch router.modal {
@@ -230,10 +226,10 @@ struct TerminalScreen: View {
if showTmuxInstall {
TmuxInstallSheet(
onRun: { cmd in
showTmuxInstall = false
withAnimation(TX.Motion.standard) { showTmuxInstall = false }
session.runInTerminal(cmd)
},
onCancel: { showTmuxInstall = false })
onCancel: { withAnimation(TX.Motion.standard) { showTmuxInstall = false } })
}
}
// host key 使
@@ -248,11 +244,9 @@ struct TerminalScreen: View {
}
}
private var sidebarPanel: SidebarPanel {
SidebarPanel(manager: manager, router: router, session: session,
tailscaleStore: tailscaleStore,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
search: $sidebarSearch)
/// / 48
private var railWidth: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
/// + / 齿
@@ -263,13 +257,15 @@ struct TerminalScreen: View {
// MARK: -
private var stage: some View {
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
ZStack(alignment: .top) {
VStack(spacing: 0) {
// tmux 2c pane
if session.tmuxUnavailable {
NoTmuxBanner(onInstall: { showTmuxInstall = true },
onDismiss: { session.dismissTmuxBanner() })
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
onDismiss: {
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
})
.padding(.top, TX.Layout.capsuleInset)
.transition(.opacity)
}
@@ -280,20 +276,35 @@ struct TerminalScreen: View {
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(RoundedRectangle(cornerRadius: 6))
.clipShape(paneShape)
.overlay(
RoundedRectangle(cornerRadius: 6).strokeBorder(p.border, lineWidth: 1)
paneShape.strokeBorder(p.border, lineWidth: 1)
// /
// 线
.padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1))
)
// pane 线
.padding([.leading, .trailing, .top], TX.Layout.paneGap)
TerminalHintBar(context: hintContext, hints: hints)
// **** 2pt /
// pane 线
.padding(.leading, TX.Layout.paneGap)
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
}
capsules
capsules(safeTop: safeTop)
}
.background(p.bgDeep)
.contentShape(Rectangle())
// Pin
.simultaneousGesture(TapGesture().onEnded { router.dismissOverlaySidebar() })
}
/// pane ** app chrome **
///
/// iPad/iPhone 6pt
/// ""
/// /****
/// pane chrome Stage Manager
private var paneShape: UnevenRoundedRectangle {
UnevenRoundedRectangle(
topLeadingRadius: TX.Radius.pane,
bottomLeadingRadius: TX.Radius.pane,
bottomTrailingRadius: TX.Radius.pane,
topTrailingRadius: 0)
}
@ViewBuilder private var paneArea: some View {
@@ -312,17 +323,20 @@ struct TerminalScreen: View {
router.goHome()
manager.close(id)
})
//
.animation(TX.Motion.standard, value: session.phaseText)
}
}
private var capsules: some View {
private func capsules(safeTop: CGFloat) -> some View {
HStack(alignment: .top) {
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
Spacer(minLength: TX.Space.m)
TerminalStatusCapsule(session: session)
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 14)
// iPad 14/
.padding(.top, TX.Layout.capsuleTop(safeTop))
}
/// **** `closeSession(kill:)`
@@ -337,7 +351,7 @@ struct TerminalScreen: View {
if let tmux = session.tmuxController {
if kill { tmux.killSession() } else { tmux.detach() }
}
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
router.goHome() // router TX.Motion.standard
// detach/kill tmux %exit onExit
Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
@@ -348,7 +362,7 @@ struct TerminalScreen: View {
private func minimizeSession() {
session.captureSnapshot() //
manager.minimize(session.id)
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
router.goHome()
}
///
@@ -373,20 +387,31 @@ struct TerminalScreen: View {
router.present(.splitMenu(vertical: vertical))
}
private var hintContext: String {
switch router.sidebar {
case .pinned: "Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次"
case .overlay: "遮罩态 · 点终端任意处收起,终端不 reflow"
case .collapsed: session.isLive ? "已连接妙控键盘 · 附加行已收起" : session.phaseText
/// **** ·
///
private var barContext: String {
if !session.isLive { return session.phaseText }
if let t = session.tmuxSummary {
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" }
return session.displayName
}
private var hints: [(key: String, label: String)] {
switch router.sidebar {
case .pinned: [("Pin", "解除钉住"), ("\\", "收起为轨道"), ("⌘⇧H", "回首页")]
case .overlay: [("\\", "收起"), ("Pin", "钉在左侧"), ("⌘⇧H", "回首页")]
case .collapsed: [("⌘K", "命令面板"), ("⌘D", "竖分屏"), ("⌘T", "新 window"), ("⌘⇧H", "回首页")]
}
/// ****tmux
private var barActions: [TerminalActionBar.Action] {
let tmux = session.tmuxController
return [
.init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) {
requestSplit(vertical: true)
},
.init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window",
enabled: tmux != nil) {
tmux?.newWindow()
},
.init(id: "home", icon: "house", title: "回首页") { router.goHome() },
]
}
}

View File

@@ -11,6 +11,7 @@ struct HomeView: View {
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
@State private var search = ""
@State private var groupFilter: String? = nil
@@ -41,6 +42,31 @@ struct HomeView: View {
}
var body: some View {
//
TXScreenMetrics { geo in
content(safeBottom: geo.safeAreaInsets.bottom)
}
.background(p.bg.ignoresSafeArea())
//
.task(id: manager.sessions.count) {
while !Task.isCancelled {
for s in manager.sessions { s.captureSnapshot() }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore,
existingGroups: groups) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
}
private func content(safeBottom: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 0) {
header
if hostStore.hosts.isEmpty && manager.sessions.isEmpty {
@@ -71,29 +97,16 @@ struct HomeView: View {
hostGrid
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.bottom, TX.Space.xxl)
.padding(.horizontal, gutter)
// home indicator
.padding(.bottom, TX.Space.xxl + safeBottom)
}
//
.ignoresSafeArea(edges: .bottom)
// /线/
.animation(TX.Motion.standard, value: manager.sessions.count)
}
}
.background(p.bg.ignoresSafeArea())
//
.task(id: manager.sessions.count) {
while !Task.isCancelled {
for s in manager.sessions { s.captureSnapshot() }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore,
existingGroups: groups) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
}
// MARK: -
@@ -178,22 +191,30 @@ struct HomeView: View {
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
Spacer()
Spacer(minLength: TX.Space.s)
HStack(spacing: TX.Space.s) {
TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K")
.frame(width: 220)
TXSearchField(text: $search, placeholder: "搜索主机")
// 220 / iPhone
//
.frame(minWidth: 120, maxWidth: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
.fixedSize()
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.horizontal, gutter)
.padding(.top, TX.Space.l)
.padding(.bottom, TX.Space.m)
}
/// iPad 40pt 20pt
private var gutter: CGFloat {
hSize == .compact ? TX.Space.l : TX.Space.screenGutter
}
private var subtitle: String {
var parts: [String] = []
if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") }
@@ -213,60 +234,7 @@ struct HomeView: View {
}
}
/// tmux
/// `SessionManager` manager
struct ConnectingRow: View {
@ObservedObject var session: TerminalSession
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var failed: Bool { session.phaseText.hasPrefix("失败") }
var body: some View {
HStack(spacing: TX.Space.s) {
if failed {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 13))
.foregroundStyle(p.danger)
} else {
ProgressView().controlSize(.small).tint(TXAccent.base)
}
VStack(alignment: .leading, spacing: 2) {
Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11))
.foregroundStyle(failed ? p.danger : p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.s)
if failed {
TXButton(title: "重试") { session.retry() }
}
TXButton(title: failed ? "移除" : "取消", action: onCancel)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1)
)
.padding(.top, TX.Space.xs)
}
private var detail: String {
if failed { return String(session.phaseText.dropFirst(3)) }
if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" }
if !session.banner.isEmpty { return session.banner }
return session.phaseText
}
}
// MARK: - 3
// MARK: -
/// **** ConnectingRow
/// tmux
@@ -277,8 +245,15 @@ struct ConnectingRow: View {
}
}
/// ** 3 ** 3 **4:3**
///
/// 3
/// 3
private var sessionCards: some View {
HStack(alignment: .top, spacing: TX.Space.m) {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 3),
spacing: TX.Space.m
) {
ForEach(liveSessions) { s in
SessionCard(
session: s,
@@ -288,15 +263,17 @@ struct ConnectingRow: View {
manager.focus(s)
router.enter(s.id)
}
.aspectRatio(4.0 / 3.0, contentMode: .fit)
}
}
.frame(height: 560)
}
// MARK: - 4
private var hostGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 4),
// 4 iPad 2
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m),
count: hSize == .compact ? 2 : 4),
spacing: TX.Space.m) {
ForEach(filteredHosts) { host in
HostTile(host: host,
@@ -336,7 +313,7 @@ struct ConnectingRow: View {
.foregroundStyle(p.border)
)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
private func credName(for host: SavedHost) -> String? {
@@ -428,27 +405,36 @@ struct SessionCard: View {
}
}
/// **** Button
/// affordance
var body: some View {
VStack(spacing: 0) {
cardHead
Rectangle().fill(p.divider).frame(height: 1)
// 线/ + blur(1.5px) + rgba(24,26,40,.72)
//
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
.blur(radius: isTroubled ? 1.5 : 0)
.overlay { if isTroubled { troubleVeil } }
Rectangle().fill(p.divider).frame(height: 1)
cardFoot
Button(action: onOpen) {
VStack(spacing: 0) {
cardHead
Rectangle().fill(p.divider).frame(height: 1)
// 线/ + blur(1.5px) + rgba(24,26,40,.72)
//
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
.blur(radius: isTroubled ? 1.5 : 0)
.overlay { if isTroubled { troubleVeil } }
Rectangle().fill(p.divider).frame(height: 1)
cardFoot
}
// ****`.frame(height:)` 560
// 10pt
// LazyVGrid HStack
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.surfaceDark)
.clipShape(RoundedRectangle(cornerRadius: 13))
.overlay(
RoundedRectangle(cornerRadius: 13)
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
)
.txShadow(isFocused ? TX.Elevation.cardFocused : TX.Elevation.card)
.animation(TX.Motion.quick, value: isFocused)
.contentShape(Rectangle())
}
.background(p.surfaceDark)
.clipShape(RoundedRectangle(cornerRadius: 13))
.overlay(
RoundedRectangle(cornerRadius: 13)
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(isFocused ? 0.4 : 0.2), radius: isFocused ? 9 : 5, y: 6)
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
.buttonStyle(.txPress)
}
private var cardHead: some View {
@@ -528,12 +514,9 @@ struct SessionCard: View {
Text("\(shortcutIndex)")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
Button(action: onOpen) {
Text(isTroubled ? "立即重试" : "回到会话 →")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(TXAccent.textBright)
}
.buttonStyle(.plain)
Text(isTroubled ? "立即重试" : "回到会话 →")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(TXAccent.textBright)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 38)
@@ -590,9 +573,11 @@ struct HostTile: View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: TX.Space.s) {
HStack(alignment: .top, spacing: TX.Space.s) {
// Ubuntu / Debian / macOS
// OS
Image(systemName: icon)
.font(.system(size: 15, weight: .light))
.foregroundStyle(p.text4)
.foregroundStyle(TXTech.osTint(host.os, p))
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
@@ -614,9 +599,12 @@ struct HostTile: View {
}
}
HStack(spacing: 5) {
if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") }
if hasTsnet { TXBadge(text: "tsnet") }
if let credName { TXBadge(text: credName, emphasized: true) }
// tsnet app
// TXTech
let transport = TXTech.transport(host.useMosh, p)
TXBadge(text: transport.text, tint: transport.tint)
if hasTsnet { TXBadge(text: "tsnet", emphasized: true) }
if let credName { TXBadge(text: credName) }
}
}
.padding(TX.Space.s)
@@ -627,6 +615,6 @@ struct HostTile: View {
.strokeBorder(p.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}

View File

@@ -146,7 +146,7 @@ struct HostCard: View {
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
private func badge(_ icon: String, _ text: String, _ color: Color) -> some View {

View File

@@ -1,158 +1,380 @@
import SwiftUI
// 稿 `1b -` 56pt + `3a -/Pin ` 288pt
// 56pt 288pt****
//
// ****`` App logo
// »«Pin
//
// " + "
// 288pt railWidth `clipped()`
// =
// railWidth ****logo() / / / / window /
// / 齿****
//
// /
// 稿Logo =»«
//
// ****`clipped()` //window
// `LeadingHitShape`
// Pin window
// `allowsHitTesting(expanded)`
/// 56pt 48pt线
struct RailView: View {
/// `width`
private struct LeadingHitShape: Shape {
var width: CGFloat
var animatableData: CGFloat {
get { width }
set { width = newValue }
}
func path(in rect: CGRect) -> Path {
Path(CGRect(x: 0, y: 0, width: width, height: rect.height))
}
}
struct TerminalSidebar: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
/// TMUX window
/// tmux window
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
/// 48 = 线
let railWidth: CGFloat
/// home indicator
let safeBottom: CGFloat
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
private var width: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 }
private var expanded: Bool { router.sidebar.isExpanded }
private var currentWidth: CGFloat { expanded ? TX.Layout.sidebarWidth : railWidth }
// 稿 `1b` 1366×1024
// logo 28..62 · 75..109 · 118..152 · 160 · 174/220/266 8
// · 线+ 313..351 · 370 · TMUX 382 · window 408 7
// · 齿 985..1019 · 932..967 · 914
var body: some View {
VStack(spacing: 0) {
logo
.padding(.top, 28)
.padding(.bottom, 13)
// / / 稿**** logo
content
.frame(width: TX.Layout.sidebarWidth, alignment: .leading)
.background(p.surfaceDark)
.frame(width: currentWidth, alignment: .leading)
.clipped()
}
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
header
searchRow
.padding(.bottom, TX.Space.s)
homeRow
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
sessionRows
newHostRow
tmuxDivider
tmuxLabelRow
tmuxRows
Spacer(minLength: TX.Space.s)
tailscaleRow
footRow
}
}
// MARK: -
/// Logo ****稿 App Logo
/// » Logo``
private var header: some View {
HStack(spacing: 0) {
ZStack {
logoBox { Image(systemName: "chevron.right.2").font(.system(size: 13, weight: .semibold)) }
.opacity(expanded ? 0 : 1)
logoBox { Text("").font(TX.Font.mono(15, .bold)) }
.opacity(expanded ? 1 : 0)
}
.frame(width: railWidth)
.contentShape(Rectangle())
.onTapGesture { if !expanded { router.toggleSidebar() } }
.accessibilityLabel(expanded ? "terminalX" : "展开侧边栏")
.accessibilityAddTraits(expanded ? [] : .isButton)
HStack(spacing: TX.Space.s) {
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer(minLength: 0)
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起侧边栏") {
router.toggleSidebar()
}
}
.padding(.trailing, TX.Space.s)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
.padding(.top, 14)
.padding(.bottom, 10)
}
/// Logo / 30×30
private func logoBox(@ViewBuilder _ inner: () -> some View) -> some View {
inner()
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
}
/// = =
/// 12 + 10 + 6 railWidth/2""
private var searchRow: some View {
ZStack(alignment: .leading) {
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
router.toggleSidebar()
}
.padding(.bottom, 9)
TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() }
.padding(.bottom, 8)
divider
.padding(.bottom, 13)
hostAvatars
newHostButton
divider
.padding(.top, 9)
.padding(.bottom, 12)
tmuxSection
Spacer(minLength: TX.Space.s)
TXStatusDot(color: linkColor, punchOut: p.surfaceDark)
.padding(.bottom, 11)
// 稿****
TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") {
router.toggleSidebar()
}
.padding(.bottom, 18)
TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings)
.padding(.bottom, 5)
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
.allowsHitTesting(!expanded)
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.leading, 12)
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
.frame(width: width)
.background(p.surfaceDark)
}
/// App logo``****
private var logo: some View {
Text("")
.font(TX.Font.mono(16, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 34, height: 34)
.background(p.surface, in: RoundedRectangle(cornerRadius: 11))
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1))
.accessibilityLabel("terminalX")
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: 0) {
Image(systemName: "house")
.font(.system(size: 17))
.foregroundStyle(p.text3)
.frame(width: railWidth)
HStack {
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer(minLength: 0)
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 36)
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("首页")
}
private var divider: some View {
// MARK: - 线
/// = = 24pt 线""
private func sectionRow(label: String, trailing: String? = nil) -> some View {
ZStack(alignment: .leading) {
shortDivider
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
TXSectionLabel(text: label, trailing: trailing)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 26)
}
private var shortDivider: some View {
Rectangle()
.fill(p.divider)
.frame(width: 24, height: 1)
}
private var hostAvatars: some View {
VStack(spacing: 8) {
ForEach(manager.activeSessions) { s in
Button {
manager.focus(s)
router.enter(s.id)
} label: {
TXInitialsAvatar(initials: s.initials,
size: avatarSize,
isSelected: router.route.sessionID == s.id,
status: RailSidebarStyle.statusColor(s, p),
punchOut: p.surfaceDark)
}
.buttonStyle(.plain)
// MARK: -
@ViewBuilder private var sessionRows: some View {
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, railWidth)
.frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s,
isSelected: router.route.sessionID == s.id,
expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) {
manager.focus(s)
router.enter(s.id)
}
}
}
/// 线 +
private var newHostButton: some View {
/// 线 +
private var newHostRow: some View {
Button(action: onNewHost) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: avatarSize, height: avatarSize)
.background(
RoundedRectangle(cornerRadius: 11)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
HStack(spacing: 0) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 30, height: 30)
.background(
RoundedRectangle(cornerRadius: 9)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
.frame(width: railWidth)
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: 40)
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.plain)
.padding(.top, 9)
.buttonStyle(.txPress)
.accessibilityLabel("连接新主机")
}
/// TMUX + window tmux
private var tmuxSection: some View {
VStack(spacing: 7) {
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
// MARK: - tmux
/// = 3a = 线
private var tmuxDivider: some View {
ZStack(alignment: .leading) {
shortDivider
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.padding(.vertical, TX.Space.s)
}
/// = accent TXTech tmux
/// = tmux · ·
private var tmuxLabelRow: some View {
ZStack(alignment: .leading) {
Text(session.tmuxSummary == nil ? "窗口" : "tmux")
.font(TX.Font.mono(9, .semibold))
.tracking(0.9)
// 稿 TMUX accent-500
.foregroundStyle(TXAccent.mid)
.padding(.bottom, 9)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
Button { tmux.selectWindow(win.id) } label: {
windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID)
}
.buttonStyle(.plain)
}
} else {
// attach window
ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in
windowNumber("\(i)", isActive: i == 0)
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 22)
.padding(.bottom, 4)
}
@ViewBuilder private var tmuxRows: some View {
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID,
expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// tmux controller attach
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
WindowRowBody(
index: "\(i)",
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
subtitle: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane",
isSelected: i == 0,
expanded: expanded, railWidth: railWidth)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, railWidth)
.frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0)
}
}
/// window 34×30 +
private func windowNumber(_ text: String, isActive: Bool) -> some View {
Text(text)
.font(TX.Font.mono(12, isActive ? .semibold : .regular))
.foregroundStyle(isActive ? p.text : p.text4)
.frame(width: 34, height: 30)
.background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1)
)
/// window T
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 0) {
Color.clear.frame(width: railWidth, height: 1)
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer(minLength: 0)
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
.padding(.trailing, TX.Space.m)
}
.frame(height: 32)
}
.buttonStyle(.txPress)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
private var linkColor: Color { RailSidebarStyle.statusColor(session, p) }
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "tmux · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "tmux · \(host)"
}
// MARK: - tailnet · 线 + 齿
/// tailnet
private var tailscaleRow: some View {
HStack(spacing: 0) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online,
punchOut: p.surfaceDark)
.frame(width: railWidth)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: 40)
}
/// 线线 y footBand 齿
private var footRow: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
Button(action: onSettings) {
HStack(spacing: 0) {
Image(systemName: "gearshape")
.font(.system(size: 17))
.foregroundStyle(p.text3)
.frame(width: railWidth)
Text("设置")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: TX.Layout.footBand(safeBottom))
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("设置")
}
}
}
///
@@ -168,283 +390,60 @@ enum RailSidebarStyle {
}
}
// MARK: - 288pt
// MARK: -
/// / Pin
///
/// = 26pt + 10pt gapwindow = 26×22pt + 10pt gap
/// **inset **`strokeBorder` border 1pt
struct SidebarPanel: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.horizontal, TX.Space.m)
.padding(.bottom, TX.Space.s)
homeRow
sessionsSection
// 1pt ****线
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
tmuxSection
Spacer(minLength: 0)
tailscaleFoot
}
.frame(width: TX.Layout.sidebarWidth)
.background(p.surfaceDark)
}
private var header: some View {
HStack(spacing: TX.Space.s) {
Text("")
.font(TX.Font.mono(15, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer()
// Pin
TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned,
label: "钉在左侧") { router.togglePin() }
//
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() }
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 11)
.padding(.bottom, 6)
}
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "house")
.font(.system(size: 13))
.foregroundStyle(p.text2)
.frame(width: 26)
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 36)
}
.buttonStyle(.plain)
}
private var sessionsSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, 53)
.frame(height: 32, alignment: .leading)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) {
manager.focus(s)
router.enter(s.id)
}
}
Button(action: onNewHost) {
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.padding(.leading, 53) //
.frame(height: 32, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
}
private var tmuxSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.padding(.bottom, 2)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// tmux controller attach
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
placeholderWindowRow(
index: i,
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
detail: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "",
isSelected: i == 0)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.xs)
}
}
}
/// window T window x=53
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer()
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.leading, 53)
.padding(.trailing, TX.Space.m)
.frame(height: 32)
}
.buttonStyle(.plain)
}
/// controller window SidebarWindowRow
private func placeholderWindowRow(index: Int, title: String, detail: String,
isSelected: Bool) -> some View {
HStack(spacing: TX.Space.s) {
Text("\(index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
if !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
}
}
Spacer(minLength: 0)
if isSelected { TXStatusDot(color: p.online, size: 7) }
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "TMUX · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "TMUX · \(host)"
}
/// tailnet 绿 + tailnet + IP
///
/// **** `TX.Layout.footHeight`线 y
/// 线 1pt divider稿
private var tailscaleFoot: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
HStack(spacing: TX.Space.s) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
// tailnet + tailnet IPUp SelfIP
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}
}
/// 26pt + ·window title + /
/// 30pt + ·window title +
///
struct SidebarSessionRow: View {
@ObservedObject var session: TerminalSession
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
let hitWidth: CGFloat
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
TXInitialsAvatar(initials: session.initials, size: 26,
HStack(spacing: 0) {
TXInitialsAvatar(initials: session.initials, size: 30,
isSelected: isSelected,
status: RailSidebarStyle.statusColor(session, p),
punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
punchOut: isSelected && expanded ? TXAccent.selectedBg : p.surfaceDark)
.frame(width: railWidth)
HStack(spacing: TX.Space.s) {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
trailingTag
}
Spacer(minLength: TX.Space.xs)
trailingTag
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 48)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
// inset border 1pt
.overlay(
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
.fill(TXAccent.selectedBg)
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.opacity(isSelected && expanded ? 1 : 0)
)
.padding(.horizontal, TX.Space.s)
.contentShape(LeadingHitShape(width: hitWidth))
.animation(TX.Motion.quick, value: isSelected)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
private var title: String {
@@ -473,13 +472,14 @@ struct SidebarSessionRow: View {
}
}
/// window 26×22pt + title + cwd/panes
/// window 26×22 + title + cwd/panes
struct SidebarWindowRow: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
let hitWidth: CGFloat
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var subtitle: String {
var parts: [String] = []
@@ -491,19 +491,44 @@ struct SidebarWindowRow: View {
var body: some View {
Button(action: onTap) {
WindowRowBody(index: "\(window.index)", title: window.title, subtitle: subtitle,
isSelected: isSelected, expanded: expanded, railWidth: railWidth)
.contentShape(LeadingHitShape(width: hitWidth))
}
.buttonStyle(.txPress)
}
}
/// window SidebarWindowRow
struct WindowRowBody: View {
let index: String
let title: String
let subtitle: String
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 0) {
Text(index)
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.frame(width: railWidth)
HStack(spacing: TX.Space.s) {
Text("\(window.index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(window.title)
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
// cwd · n panescwd #{pane_current_path}
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
@@ -514,16 +539,20 @@ struct SidebarWindowRow: View {
TXStatusDot(color: p.online, size: 7)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.buttonStyle(.plain)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: TX.Radius.control)
.fill(TXAccent.selectedBg.opacity(0.75))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.opacity(isSelected && expanded ? 1 : 0)
)
.animation(TX.Motion.quick, value: isSelected)
}
}

View File

@@ -84,7 +84,7 @@ struct SessionPickerSheet: View {
.background(p.surface, in: Capsule())
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
.accessibilityLabel("直接连接原生终端,不经过 tmux")
}
}
@@ -115,7 +115,7 @@ struct SessionPickerSheet: View {
Button { onAttach(s.name) } label: {
sessionRow(s)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
if index < choice.sessions.count - 1 {
Rectangle().fill(p.divider).frame(height: 1)
.padding(.horizontal, TX.Space.m)
@@ -200,7 +200,7 @@ struct SessionPickerSheet: View {
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
}

View File

@@ -69,7 +69,7 @@ struct SettingsView: View {
Section("主题app 与终端统一)") {
ForEach(TXPalette.all) { pal in
Button { theme.apply(pal) } label: { themeRow(pal) }
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
.listRowBackground(p.surface)

View File

@@ -2,6 +2,57 @@ import SwiftUI
// 稿 / / docs/design/UI-DESIGN-HANDOFF.md
// MARK: -
extension View {
/// `TX.Elevation` `.shadow`
func txShadow(_ s: TX.Shadow) -> some View {
shadow(color: .black.opacity(s.opacity), radius: s.radius, x: s.x, y: s.y)
}
}
/// +
///
/// `.plain` **** ""
/// /
struct TXPressStyle: ButtonStyle {
///
var scale: CGFloat = 0.985
var dim: Double = 0.78
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? scale : 1)
.opacity(configuration.isPressed ? dim : 1)
.animation(TX.Motion.quick, value: configuration.isPressed)
}
}
extension ButtonStyle where Self == TXPressStyle {
/// /
static var txPress: TXPressStyle { TXPressStyle() }
///
static var txPressTight: TXPressStyle { TXPressStyle(scale: 0.9, dim: 0.7) }
}
/// +
///
/// 1. ****`ignoresSafeArea`
/// + SwiftUI
/// `ignoresSafeArea` ****
/// 2. **** 560pt // iPad
///
///
struct TXScreenMetrics<Content: View>: View {
@ViewBuilder let content: (GeometryProxy) -> Content
var body: some View {
GeometryReader { geo in
content(geo)
}
}
}
/// 8pt + 2pt `box-shadow: 0 0 0 2px <>`/
struct TXStatusDot: View {
let color: Color
@@ -18,6 +69,8 @@ struct TXStatusDot: View {
Circle().stroke(punchOut, lineWidth: 2)
}
}
// 绿
.animation(TX.Motion.quick, value: color)
}
}
@@ -55,7 +108,11 @@ struct TXInitialsAvatar: View {
}
}
/// 600 10px, ls .09em, uppercase
/// 600 10px, ls .09em
///
/// ** `.uppercased()`**//`tmux · mac-studio · dev`
/// `TMUX · MAC-STUDIO · DEV`
/// `TXTech`
struct TXSectionLabel: View {
let text: String
/// 3 3
@@ -66,7 +123,7 @@ struct TXSectionLabel: View {
var body: some View {
HStack {
Text(text.uppercased())
Text(text)
.font(TX.Font.mono(10, .semibold))
.tracking(0.9)
.foregroundStyle(color ?? p.textWeak)
@@ -80,25 +137,73 @@ struct TXSectionLabel: View {
}
}
/// mosh / tsnet / SSH / SE key
/// ****`SSH` / `mosh` / `tsnet` / `tmux` /
///
/// **** `SSH``TLS`
/// `mosh` / `tmux` / `tsnet` `macOS``TrueNAS`
/// "" `mosh` `MOSH` `SSH` `ssh`
/// ****
/// `TMUX · MAC-STUDIO · DEV`
///
/// ****"" flavor Catppuccin
/// 12%
@MainActor
enum TXTech {
///
static func transport(_ useMosh: Bool, _ p: TXPalette) -> (text: String, tint: Color) {
useMosh ? ("mosh", p.mosh) : ("SSH", p.linkPath)
}
/// / OS Ubuntu peachDebian red red
///
static func osTint(_ os: String?, _ p: TXPalette) -> Color {
let s = (os ?? "").lowercased()
if s.contains("ubuntu") { return p.reconnecting } // Ubuntu orange
if s.contains("debian") || s.contains("raspberry") { return p.danger }
if s.contains("centos") || s.contains("rhel") || s.contains("red hat") { return p.danger }
if s.contains("fedora") || s.contains("truenas") || s.contains("alpine") { return p.linkPath }
if s.contains("arch") { return p.mosh }
if s.contains("suse") { return p.online }
if s.contains("macos") || s.contains("darwin") { return p.text3 } // Apple
return p.text4
}
}
/// mosh / SSH / tsnet /
///
/// `tint` `TXTech``emphasized` app ** tsnet**
///
///
struct TXBadge: View {
let text: String
var tint: Color?
var emphasized: Bool = false
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var fg: Color { emphasized ? TXAccent.text : (tint ?? p.text4) }
private var stroke: Color {
if emphasized { return TXAccent.selectedStroke }
return tint?.opacity(0.35) ?? .clear
}
var body: some View {
Text(text)
.font(TX.Font.mono(11, .medium))
.foregroundStyle(emphasized ? TXAccent.text : p.text4)
.foregroundStyle(fg)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(emphasized ? TXAccent.selectedBg : p.surface,
in: RoundedRectangle(cornerRadius: 5))
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(emphasized ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.background {
let shape = RoundedRectangle(cornerRadius: 5)
if emphasized {
shape.fill(TXAccent.selectedBg)
} else if let tint {
shape.fill(tint.opacity(0.12))
} else {
shape.fill(p.surface)
}
}
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(stroke, lineWidth: 1))
}
}
@@ -123,8 +228,9 @@ struct TXChip: View {
RoundedRectangle(cornerRadius: TX.Radius.badge)
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.divider, lineWidth: 1)
)
.animation(TX.Motion.quick, value: isSelected)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
@@ -134,20 +240,24 @@ struct TXSearchField: View {
var placeholder: String
var shortcut: String?
@EnvironmentObject var theme: TXThemeManager
/// Tab
/// 线
@FocusState private var focused: Bool
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.xs) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12))
.foregroundStyle(p.textWeak)
.foregroundStyle(focused ? TXAccent.text : p.textWeak)
TextField(placeholder, text: $text)
.font(TX.Font.mono(12))
.foregroundStyle(p.text)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if let shortcut, text.isEmpty {
.focused($focused)
if let shortcut, text.isEmpty, !focused {
Text(shortcut)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
@@ -157,8 +267,10 @@ struct TXSearchField: View {
.frame(height: 32)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8).strokeBorder(p.divider, lineWidth: 1)
RoundedRectangle(cornerRadius: 8)
.strokeBorder(focused ? TXAccent.border : p.divider, lineWidth: 1)
)
.animation(TX.Motion.quick, value: focused)
}
}
@@ -189,7 +301,7 @@ struct TXButton: View {
.strokeBorder(emphasized ? TXAccent.border : p.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
@@ -228,8 +340,9 @@ struct TXIconButton: View {
.frame(width: hitSize ?? max(size, TX.Layout.minTouch),
height: hitSize ?? max(size, TX.Layout.minTouch))
.contentShape(Rectangle())
.animation(TX.Motion.quick, value: isSelected)
}
.buttonStyle(.plain)
.buttonStyle(.txPressTight)
.accessibilityLabel(label ?? icon)
}
}
@@ -251,13 +364,24 @@ struct TXTerminalMirror: View {
var placeholder: String = "等待输出…"
var body: some View {
// **** `Color.clear` overlay overlay
// 20pt
// `.frame(height:)` VStack
// `clipped()`
Color.clear
//
.overlay(alignment: lines.isEmpty ? .center : .bottomLeading) { rows }
.background(background)
.clipped()
}
private var rows: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer(minLength: 0)
if lines.isEmpty {
Text(placeholder)
.font(TX.Font.mono(fontSize))
.foregroundStyle(p.text4)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.frame(maxWidth: .infinity, alignment: .center)
}
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line.isEmpty ? " " : line)
@@ -272,9 +396,6 @@ struct TXTerminalMirror: View {
}
.padding(.horizontal, 14)
.padding(.vertical, TX.Space.xs)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.background(background)
.clipped()
}
/// `readViewportText()` ANSI /

View File

@@ -6,6 +6,10 @@ import SwiftUI
// 86pt padding clipped
/// blur + + + 11
///
/// ****`TX.Elevation.floating`
/// `0 10px 30px rgba(0,0,0,.55)`
/// "" .92
private struct FloatingCapsule<Content: View>: View {
@EnvironmentObject var theme: TXThemeManager
@ViewBuilder let content: Content
@@ -17,14 +21,14 @@ private struct FloatingCapsule<Content: View>: View {
.frame(height: 34)
.background {
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.fill(p.surfaceDark.opacity(0.88))
.fill(p.surfaceDark.opacity(0.92))
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
}
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(0.55), radius: 15, y: 10)
.txShadow(TX.Elevation.floating)
}
}
@@ -39,10 +43,17 @@ struct TerminalTitleCapsule: View {
var body: some View {
FloatingCapsule {
HStack(spacing: TX.Space.xs) {
windowDot(TXWindowDot.close, "关闭会话", action: onClose)
windowDot(TXWindowDot.minimize, "最小化", action: onMinimize)
// 沿 macOS
// windowButton
windowButton(icon: "xmark", tint: TXWindowDot.close,
label: "关闭会话", action: onClose)
// chevron 9.5pt
//
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
label: "最小化到首页", action: onMinimize)
Rectangle().fill(p.border).frame(width: 1, height: 14)
.padding(.horizontal, 2)
.padding(.leading, 4)
.padding(.trailing, 2)
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
@@ -54,7 +65,8 @@ struct TerminalTitleCapsule: View {
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 稿 08 · 168×44 @Published
// 稿 08 · 168×44 resize
// GridBox.onGridChange session.objectWillChange
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
@@ -91,16 +103,30 @@ struct TerminalTitleCapsule: View {
return "\(g.cols)×\(g.rows)"
}
private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View {
/// /
///
/// macOS 13pt ****
/// mac hover hover 13pt
/// "" mac app
///
///
/// 22pt + ****`` / `` =
/// == 14% / 26×34
private func windowButton(icon: String, tint: Color, label: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
Circle()
.fill(color)
.frame(width: 13, height: 13)
.overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
.frame(width: 22, height: 30)
Image(systemName: icon)
.font(.system(size: 9.5, weight: .bold))
.foregroundStyle(tint)
.frame(width: 22, height: 22)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7))
.overlay(
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
)
.frame(width: 26, height: 34)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPressTight)
.accessibilityLabel(label)
}
}
@@ -287,6 +313,8 @@ struct TerminalStateOverlay: View {
}
.padding(TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// "" paneArea phaseText
.transition(.opacity)
}
}
@@ -316,7 +344,7 @@ struct NoTmuxBanner: View {
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPressTight)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
@@ -369,38 +397,74 @@ struct TmuxInstallSheet: View {
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
.txShadow(TX.Elevation.dialog)
}
}
}
/// 34ptUI-5
struct TerminalHintBar: View {
/// Pin · / resize
///
///
/// ****iPad / window /
/// `D`
/// UI-5 线
///
/// = 34pt + home indicator **** 齿
/// 线 34pt 24pt ""
struct TerminalActionBar: View {
/// /
let context: String
let hints: [(key: String, label: String)]
let actions: [Action]
let safeBottom: CGFloat
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
struct Action: Identifiable {
let id: String
let icon: String
let title: String
/// tmux
var enabled: Bool = true
let run: () -> Void
}
/// iPhone 34pt
private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions }
var body: some View {
HStack(spacing: TX.Space.s) {
Text(context)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.m)
ForEach(hints, id: \.key) { hint in
HStack(spacing: 5) {
Text(hint.key)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text4)
Text(hint.label)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
Spacer(minLength: TX.Space.s)
ForEach(shown) { action in
Button(action: action.run) {
HStack(spacing: 5) {
Image(systemName: action.icon)
.font(.system(size: 10.5, weight: .medium))
Text(action.title)
.font(TX.Font.mono(11.5, .medium))
}
.foregroundStyle(action.enabled ? p.text2 : p.textFaint)
.padding(.horizontal, TX.Space.xs)
.frame(height: 26)
.background(action.enabled ? p.surface : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.badge))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.badge)
.strokeBorder(action.enabled ? p.border : p.divider, lineWidth: 1)
)
// ****
.lineLimit(1)
.fixedSize()
}
.buttonStyle(.txPress)
.disabled(!action.enabled)
}
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
.frame(height: TX.Layout.footBand(safeBottom))
.animation(TX.Motion.standard, value: context)
}
}

View File

@@ -7,19 +7,24 @@ import SwiftUI
// - ** / W = **`esc` ``
/// + /
///
/// 96% `TX.Motion.pop`
/// `.transition(.opacity)`
/// `AppRouter.present/dismissModal`
struct TXModalScrim<Content: View>: View {
let onDismiss: () -> Void
@ViewBuilder let content: Content
var body: some View {
ZStack {
Color(hex: 0x0A0B12).opacity(0.42)
TX.Scrim.color.opacity(TX.Scrim.dialog)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
.transition(.opacity)
content
.transition(TX.Motion.pop)
}
.transition(.opacity)
}
}
@@ -60,7 +65,7 @@ struct CloseConfirmDialog: View {
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
.txShadow(TX.Elevation.dialog)
}
}
@@ -102,7 +107,7 @@ struct CloseConfirmDialog: View {
.font(TX.Font.mono(12, confirmingKill ? .semibold : .regular))
.foregroundStyle(p.danger)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
Spacer()
TXButton(title: "取消", action: onCancel)
@@ -129,7 +134,7 @@ struct CloseConfirmDialog: View {
.strokeBorder(Color(hex: 0x8D5560), lineWidth: 1)
)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
@@ -201,7 +206,7 @@ struct SplitMenuView: View {
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
.txShadow(TX.Elevation.dialog)
}
}
@@ -273,7 +278,7 @@ struct SplitMenuView: View {
.padding(.horizontal, TX.Space.xs)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
.disabled(disabled)
}
}

View File

@@ -128,15 +128,20 @@ final class TmuxByteChannel: @unchecked Sendable {
final class GridBox: @unchecked Sendable {
private let lock = NSLock()
private var cols = 80, rows = 24, cellW = 0, cellH = 0
/// **** resize
/// TerminalSession objectWillChange
var onGridChange: (@Sendable () -> Void)?
var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH)
}
func set(cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock()
let changed = self.cols != max(1, cols) || self.rows != max(1, rows)
self.cols = max(1, cols); self.rows = max(1, rows)
if cellW > 0 { self.cellW = cellW } // cell
if cellH > 0 { self.cellH = cellH }
lock.unlock()
if changed { onGridChange?() }
}
}
@@ -371,6 +376,11 @@ final class TerminalSession: ObservableObject, Identifiable {
self.state = state
self.gate = OutputGate(session: session)
// 线 screenGrid
screenGrid.onGridChange = { [weak self] in
Task { @MainActor in self?.objectWillChange.send() }
}
// tmux enter controller+bytes
let ch = tmuxChannel
Task { @MainActor [weak self] in

View File

@@ -1,4 +1,5 @@
import SwiftUI
import UIKit
import GhosttyTerminal
import GhosttyTheme
@@ -257,6 +258,9 @@ enum TX {
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
@@ -291,8 +295,94 @@ enum TX {
static let minTouch: CGFloat = 44
/// pane
static let capsuleInset: CGFloat = 86
/// ****线 y
/// **** / / 齿线
/// 线 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 {

View File

@@ -242,7 +242,8 @@ enum UIPreviewFixture {
manager.focus(s)
router.enter(s.id)
case "sidebar":
if path.dropFirst().first == "pin" { router.togglePin() } else { router.toggleSidebar() }
// pin toggle
router.toggleSidebar()
default:
return false
}