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:
@@ -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% 遮罩,点终端任意处收起。
|
||||
// 动效:遮罩淡入 + 面板从左滑入(200–260ms 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)
|
||||
// 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 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() },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user