feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法 偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测 格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏 格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证) - 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 + TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容 - 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部 tab,侧边栏改列活动会话;底部动作条合并状态行 - Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需, 缺失时 socket 静默超时) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
|
||||
// 终端顶带(`1b` 的浮起胶囊已按用户裁决落地为**真实 chrome 行**):按钮、标题、tab 全在
|
||||
// 终端**外**的 bgDeep 带上,终端 pane 从带下方开始 —— 不再有 86pt 预留带、不再遮内容。
|
||||
//
|
||||
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
|
||||
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
||||
// 顶带层级(一眼一个主角):
|
||||
// [✕ ⌄ 独立按钮组] · 弱化标题行(纯文本,逐级降灰) · [tab 组 —— 唯一有容器感的元素]
|
||||
// tab 组 = tmux window 的段式切换槽(window 映射原生 tab 的惯例);活动会话切换在侧边栏;
|
||||
// 状态文字在底部动作条的合并状态行(见 TerminalActionBar.statusLine)。
|
||||
|
||||
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
||||
/// 终端顶带:窗口按钮组 + 弱化标题 + tmux window tab 组。
|
||||
///
|
||||
/// **扁平**:分层交给描边和材质,投影只留一点点(`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
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 34)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||
.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)
|
||||
)
|
||||
.txShadow(TX.Elevation.floating)
|
||||
}
|
||||
}
|
||||
|
||||
/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。
|
||||
struct TerminalTitleCapsule: View {
|
||||
/// 与旧「标题胶囊」的三点不同(用户定稿):① ✕/⌄ **分离成独立悬浮按钮组**,不再和标题挤在
|
||||
/// 一个胶囊里 —— 危险操作(关闭)与身份信息(主机名)不该共用一个容器;② 标题**弱化**为
|
||||
/// 纯文本行(顶带在终端外,无需胶囊底衬托对比度);③ tab 组是顶带唯一带容器的元素,
|
||||
/// 视觉重心让给「切 window」这个高频动作。
|
||||
struct TerminalTopBar: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
let onClose: () -> Void
|
||||
let onMinimize: () -> Void
|
||||
@@ -41,51 +22,55 @@ struct TerminalTitleCapsule: View {
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
FloatingCapsule {
|
||||
HStack(spacing: TX.Space.m) {
|
||||
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右)。
|
||||
HStack(spacing: TX.Space.xs) {
|
||||
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右),但形态改成带字形的按钮,
|
||||
// 详见 windowButton。
|
||||
windowButton(icon: "xmark", tint: TXWindowDot.close,
|
||||
label: "关闭会话", action: onClose)
|
||||
// 向下的 chevron 而不是双向内收箭头:后者在 9.5pt 下糊成一团认不出。
|
||||
// 向下同时对上设计里的「下滑最小化」手势语义。
|
||||
// 向下的 chevron:对上「下滑最小化 = 缩回首页卡片」的手势语义。
|
||||
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
|
||||
label: "最小化到首页", action: onMinimize)
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 14)
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, 2)
|
||||
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
|
||||
Text(session.displayName)
|
||||
.font(TX.Font.mono(12.5, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
.lineLimit(1)
|
||||
if !contextLine.isEmpty {
|
||||
Text(contextLine)
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
// 终端格子数(设计稿 08 的「· 168×44」)。侧边栏开合会 resize ——
|
||||
// 数字经 GridBox.onGridChange → session.objectWillChange 跟着刷新。
|
||||
Text(gridLabel)
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
}
|
||||
titleLine
|
||||
TmuxWindowTabGroup(session: session)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 40)
|
||||
}
|
||||
|
||||
/// 弱化标题行:状态点 + 主机名(text2) + 「window title · cwd」(textWeak) + 格子数(textFaint)。
|
||||
/// 主机身份已由侧边栏选中行承担,这里只是就近确认,故整行不超过 text2 的强度。
|
||||
private var titleLine: some View {
|
||||
HStack(spacing: TX.Space.xs) {
|
||||
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 5)
|
||||
Text(session.displayName)
|
||||
.font(TX.Font.mono(12, .medium))
|
||||
.foregroundStyle(p.text2)
|
||||
.lineLimit(1)
|
||||
if !contextLine.isEmpty {
|
||||
Text(contextLine)
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
// 终端格子数。侧边栏开合会 resize —— 数字经 GridBox.onGridChange →
|
||||
// session.objectWillChange 跟着刷新。
|
||||
Text(gridLabel)
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。
|
||||
/// 标题的上下文只留 cwd —— 当前 window title 已由 tab 组的选中 chip 承担,写两遍是重复。
|
||||
/// 无 tmux(没有 tab 组)时退回连接状态文案。
|
||||
private var contextLine: String {
|
||||
guard let t = session.tmuxSummary else {
|
||||
return session.isLive ? "" : session.phaseText
|
||||
}
|
||||
var parts: [String] = []
|
||||
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
|
||||
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
|
||||
return parts.joined(separator: " · ")
|
||||
return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd)
|
||||
}
|
||||
|
||||
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。
|
||||
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法)。
|
||||
static func abbreviate(_ path: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
|
||||
@@ -99,31 +84,29 @@ struct TerminalTitleCapsule: View {
|
||||
}
|
||||
|
||||
private var gridLabel: String {
|
||||
// tmux 模式读 controller 的客户端格子(raw 的 screenGrid 随 RawTerminalView 卸载而冻结,
|
||||
// 侧边栏开合后会显示旧值);raw 模式仍读 surface 实报的 screenGrid。
|
||||
if let g = session.tmuxController?.clientGrid, g.cols > 0 {
|
||||
return "\(g.cols)×\(g.rows)"
|
||||
}
|
||||
let g = session.screenGrid.value
|
||||
return "\(g.cols)×\(g.rows)"
|
||||
}
|
||||
|
||||
/// 关闭 / 最小化按钮。
|
||||
///
|
||||
/// 原来是 macOS 交通灯式的 13pt 纯色圆点,三处不合适:① 纯色块**不说明自己是干什么的** ——
|
||||
/// mac 上靠 hover 才浮出字形,触屏没有 hover,于是永远只是两个色点;② 13pt 命中目标在
|
||||
/// 触屏上太小,而"关闭"误触代价很高;③ 圆点是 mac 窗口的语汇,与本 app 通篇的圆角矩形
|
||||
/// 控件语汇不搭。
|
||||
///
|
||||
/// 现在:22pt 圆角方块 + **常显字形**(`✕` 关闭 / `⌄` 收下去 = 缩回首页卡片),颜色语义保留
|
||||
/// (红=关闭、琥珀=最小化),底色只上 14% 不铺满 —— 与徽标/控件同一套形状语言,命中 26×34。
|
||||
/// 关闭 / 最小化按钮:28pt 圆角方块 + **常显字形**(触屏没有 hover,纯色点说明不了自己),
|
||||
/// 颜色语义保留(红=关闭、琥珀=最小化),底色只上 14% —— 与全 app 的圆角矩形控件同语汇。
|
||||
private func windowButton(icon: String, tint: Color, label: String,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 9.5, weight: .bold))
|
||||
.font(.system(size: 10.5, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7))
|
||||
.frame(width: 28, height: 28)
|
||||
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: 8).strokeBorder(tint.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 26, height: 34)
|
||||
.frame(width: 30, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
@@ -131,84 +114,123 @@ struct TerminalTitleCapsule: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。
|
||||
/// tmux window 的 **tab 组**:一条 surfaceDark 圆角槽,window 是槽内的段式 chip。
|
||||
///
|
||||
/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格
|
||||
/// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。
|
||||
struct TerminalStatusCapsule: View {
|
||||
/// 「组」的语义:这些 tab 同属当前 tmux 会话 —— 容器即分组,不再各自悬浮。选中 chip 用
|
||||
/// accent 底+描边浮起,未选 chip 透明贴槽底;尾部 `+` 新建 window(浏览器 tab 组惯例)。
|
||||
/// controller 未 attach 但已知 tmux 概览时按占位渲染(接回前 / fixture),原生窗口不显示。
|
||||
struct TmuxWindowTabGroup: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
|
||||
var body: some View {
|
||||
if let tmux = session.tmuxController {
|
||||
WindowTabGroupRow(controller: tmux)
|
||||
} else if let summary = session.tmuxSummary {
|
||||
TabGroupSlot {
|
||||
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
|
||||
WindowTabChipBody(
|
||||
index: "\(i)",
|
||||
title: i == 0 && !summary.currentTitle.isEmpty
|
||||
? summary.currentTitle : "window \(i)",
|
||||
isSelected: i == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 真实 tmux 的 tab 组行。独立视图直接订阅 controller(嵌套 ObservableObject 不冒泡)。
|
||||
private struct WindowTabGroupRow: View {
|
||||
@ObservedObject var controller: TmuxController
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
FloatingCapsule {
|
||||
HStack(spacing: 0) {
|
||||
cell(egressText, color: egressColor, dot: true)
|
||||
separator
|
||||
cell(transportText, color: transportColor, dot: false)
|
||||
separator
|
||||
cell(tmuxText, color: p.text2, dot: false)
|
||||
TabGroupSlot {
|
||||
ForEach(controller.windows) { win in
|
||||
WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) {
|
||||
controller.selectWindow(win.id)
|
||||
}
|
||||
}
|
||||
// 组内 `+`:新建 window 的就近入口(底部动作条的大按钮仍在)。
|
||||
Button { controller.newWindow() } label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(p.text4)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
.accessibilityLabel("新建 window")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 16)
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
/// tab 组的槽体:内衬 3pt,chip 挤在同一条容器里 —— 「同组」的视觉本体。条目多时横向滚动。
|
||||
private struct TabGroupSlot<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
// 槽体(底+描边)挂在**内容层**而不是 ScrollView 上:ScrollView 恒占满剩余宽度,
|
||||
// 挂它身上就是一条拖到屏幕边的空槽 —— 槽必须紧贴 chip 才读作「一组」。
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 2) { content }
|
||||
.padding(3)
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||
.strokeBorder(p.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
|
||||
/// 单个 window chip(title/index 是 window 自己的 @Published,须直接订阅才会刷新)。
|
||||
private struct WindowTabChip: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
WindowTabChipBody(index: "\(window.index)", title: window.title, isSelected: isSelected)
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
.accessibilityLabel("切换到 window \(window.index)")
|
||||
}
|
||||
}
|
||||
|
||||
/// chip 的视觉本体(真实 window 与占位共用):`编号 标题`,选中浮起、未选贴槽底。
|
||||
private struct WindowTabChipBody: View {
|
||||
let index: String
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
if dot { TXStatusDot(color: color, size: 6) }
|
||||
Text(text)
|
||||
.font(TX.Font.mono(12, .medium))
|
||||
.foregroundStyle(color)
|
||||
Text(index)
|
||||
.font(TX.Font.mono(10.5, .semibold))
|
||||
.foregroundStyle(isSelected ? TXAccent.text : p.textFaint)
|
||||
Text(title)
|
||||
.font(TX.Font.mono(12, isSelected ? .medium : .regular))
|
||||
.foregroundStyle(isSelected ? p.text : p.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 第一格:网络可达性(tsnet 出口 / 直连)
|
||||
private var egressText: String {
|
||||
switch session.link {
|
||||
case .relay: "tsnet"
|
||||
case .direct: "direct"
|
||||
case .connecting: "连接中"
|
||||
case .reconnecting: "tsnet"
|
||||
case .offline: "离线"
|
||||
}
|
||||
}
|
||||
|
||||
private var egressColor: Color {
|
||||
switch session.link {
|
||||
case .direct, .relay: p.online
|
||||
case .connecting: p.text4
|
||||
case .reconnecting: p.reconnecting
|
||||
case .offline: p.offline
|
||||
}
|
||||
}
|
||||
|
||||
// 第二格:传输层(mosh / SSH)
|
||||
private var transportText: String {
|
||||
if case .reconnecting(let n) = session.link {
|
||||
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
|
||||
}
|
||||
return session.moshEngaged ? "mosh" : "ssh"
|
||||
}
|
||||
|
||||
private var transportColor: Color {
|
||||
if case .reconnecting = session.link { return p.reconnecting }
|
||||
// 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。
|
||||
return p.text2
|
||||
}
|
||||
|
||||
// 第三格:会话复用层
|
||||
private var tmuxText: String {
|
||||
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
|
||||
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
|
||||
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
|
||||
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
|
||||
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
|
||||
return session.isLive ? "检测 tmux…" : "—"
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 28)
|
||||
.background(isSelected ? TXAccent.selectedBg : .clear,
|
||||
in: RoundedRectangle(cornerRadius: TX.Radius.capsule - 3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule - 3)
|
||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,17 +424,22 @@ struct TmuxInstallSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 舞台底部动作条。
|
||||
/// 舞台底部动作条 + **左下状态行**。
|
||||
///
|
||||
/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「竖分屏 / 新 window / 回首页」
|
||||
/// 只写成 `⌘D` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作
|
||||
/// 左下是合并后的**唯一**状态出口(原右上状态胶囊的三格分层徽标 + 原左下上下文文字,
|
||||
/// 二者信息重复,用户裁决合并到这里):`[dot 链路] | 传输 | 会话概览`。
|
||||
/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀/红;重连中 → 第二格转琥珀;
|
||||
/// tmux 缺失 → 第三格写「无 tmux · 客户端窗口」而非报错。主机名不再重复出现
|
||||
/// (标题胶囊与当前 tab 已有)。
|
||||
///
|
||||
/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「新 window / 回首页」
|
||||
/// 只写成 `⌘T` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作
|
||||
/// 仍然可以走快捷键(UI-5),按钮照旧留着 —— 常驻可点是底线。
|
||||
///
|
||||
/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚、轨道齿轮
|
||||
/// 同一条中心线。(此前只有 34pt 且被安全区顶起,屏幕底下留一条 24pt 的空带,看着像"没铺到底"。)
|
||||
struct TerminalActionBar: View {
|
||||
/// 左侧上下文说明(会话/链路状态一句话)。
|
||||
let context: String
|
||||
@ObservedObject var session: TerminalSession
|
||||
let actions: [Action]
|
||||
let safeBottom: CGFloat
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@@ -423,20 +450,17 @@ struct TerminalActionBar: View {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
/// 不可用时灰显但仍在(如非 tmux 会话不能分屏)——不隐藏,免得布局跳。
|
||||
/// 不可用时灰显但仍在(如原生窗口不能新建 window)——不隐藏,免得布局跳。
|
||||
var enabled: Bool = true
|
||||
let run: () -> Void
|
||||
}
|
||||
|
||||
/// 窄屏只留前两个:四个在 iPhone 上放不下,会折行把 34pt 的带撑破。
|
||||
/// 窄屏只留前两个:多了在 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)
|
||||
statusLine
|
||||
Spacer(minLength: TX.Space.s)
|
||||
ForEach(shown) { action in
|
||||
Button(action: action.run) {
|
||||
@@ -465,6 +489,80 @@ struct TerminalActionBar: View {
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.frame(height: TX.Layout.footBand(safeBottom))
|
||||
.animation(TX.Motion.standard, value: context)
|
||||
.animation(TX.Motion.standard, value: contextText)
|
||||
}
|
||||
|
||||
// MARK: - 状态行(原右上状态胶囊与旧左下文字的合并体)
|
||||
|
||||
private var statusLine: some View {
|
||||
HStack(spacing: 0) {
|
||||
cell(egressText, color: egressColor, dot: true)
|
||||
separator
|
||||
cell(transportText, color: transportColor, dot: false)
|
||||
separator
|
||||
cell(contextText, color: p.text3, dot: false)
|
||||
}
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 12)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
}
|
||||
|
||||
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
if dot { TXStatusDot(color: color, size: 6) }
|
||||
Text(text)
|
||||
.font(TX.Font.mono(11.5, .medium))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 第一格:网络可达性(tsnet 出口 / 直连)
|
||||
private var egressText: String {
|
||||
switch session.link {
|
||||
case .relay: "tsnet"
|
||||
case .direct: "direct"
|
||||
case .connecting: "连接中"
|
||||
case .reconnecting: "tsnet"
|
||||
case .offline: "离线"
|
||||
}
|
||||
}
|
||||
|
||||
private var egressColor: Color {
|
||||
switch session.link {
|
||||
case .direct, .relay: p.online
|
||||
case .connecting: p.text4
|
||||
case .reconnecting: p.reconnecting
|
||||
case .offline: p.offline
|
||||
}
|
||||
}
|
||||
|
||||
// 第二格:传输层(mosh / SSH)
|
||||
private var transportText: String {
|
||||
if case .reconnecting(let n) = session.link {
|
||||
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
|
||||
}
|
||||
return session.moshEngaged ? "mosh" : "ssh"
|
||||
}
|
||||
|
||||
private var transportColor: Color {
|
||||
if case .reconnecting = session.link { return p.reconnecting }
|
||||
// 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。
|
||||
return p.text3
|
||||
}
|
||||
|
||||
// 第三格:会话复用层概览(旧左下「会话状态一句话」并入这里,windows 数保留)。
|
||||
private var contextText: String {
|
||||
if let t = session.tmuxSummary {
|
||||
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
|
||||
return "\(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||||
}
|
||||
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
|
||||
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
|
||||
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
|
||||
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
|
||||
return session.isLive ? "检测 tmux…" : session.phaseText
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user